import { useState, useEffect, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { 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 { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import { formatCurrency, formatDate, formatMultiCurrency, czechPlural, } from "../utils/formatters"; import useTableSort from "../hooks/useTableSort"; import useDebounce from "../hooks/useDebounce"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { useApiMutation } from "../lib/queries/mutations"; import { useDocumentListPdf } from "../hooks/useDocumentPdf"; import { issuedOrderListOptions, issuedOrderStatsOptions, issuedOrderSuppliersOptions, type IssuedOrder, } from "../lib/queries/issued-orders"; import { Button, Card, DataTable, Pagination, ConfirmDialog, TextField, Select, StatusChip, FilterBar, EmptyState, LoadingState, type DataColumn, } from "../ui"; import { ISSUED_ORDER_STATUS, statusLabel, statusColor, statusOptions, documentNumberLabel, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; // DELIBERATE deviation from the Offers status-Tabs row: this page is embedded // under the parent Orders page, which already renders the identical centered // kit (Vydané/Přijaté) directly above this component — a second // same-styled tab row would visually fight it (and the sibling "Přijaté" tab // filters via a Select too). So the status filter stays a Select, with the // "all" entry relabeled to match the offers tabs ("Všechny"). const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, { value: "", label: "Všechny", }); const ViewIcon = ( ); const EditIcon = ( ); const PdfIcon = ( ); const DeleteIcon = ( ); interface IssuedOrdersProps { month: number; year: number; } export default function IssuedOrders({ month, year }: IssuedOrdersProps) { const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const { sort, order, handleSort } = useTableSort("po_number"); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search, 300); const [status, setStatus] = useState(""); const [supplierFilter, setSupplierFilter] = useState(""); const [page, setPage] = useState(1); // Reset to page 1 when the parent moves to another month — the kept page // index would otherwise request e.g. page 2 of a sparser month and render // an empty list. Adjusted during render (adjust-state-on-prop-change // pattern); a remount via `key` would also clear search/filters. const [prevMonthYear, setPrevMonthYear] = useState(`${month}-${year}`); if (`${month}-${year}` !== prevMonthYear) { setPrevMonthYear(`${month}-${year}`); setPage(1); } // Track first successful load so later refetches (filter/status/page change) // keep the table visible instead of flashing the full-page skeleton. const hasLoadedOnce = useRef(false); const { data: suppliers } = useQuery(issuedOrderSuppliersOptions()); // Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent // close, blob-URL revoke + unmount cleanup) — same hook as the Offers list. const { openPdf, pdfLoadingId } = useDocumentListPdf(); const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; order: IssuedOrder | null; }>({ show: false, order: null }); const deleteMutation = useApiMutation< { id: number }, { message?: string; error?: string } >({ url: ({ id }) => `${API_BASE}/issued-orders/${id}`, method: () => "DELETE", invalidate: ["issued-orders"], onSuccess: (data) => { setDeleteConfirm({ show: false, order: null }); alert.success(data?.message || "Objednávka byla smazána"); }, }); const { items: orders, pagination, isPending, isFetching, } = usePaginatedQuery( issuedOrderListOptions({ search: debouncedSearch, sort, order, page, status: status || undefined, supplier_id: supplierFilter || undefined, month, year, }), ); // 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( issuedOrderStatsOptions({ search: debouncedSearch, status: status || undefined, supplier_id: supplierFilter || undefined, month, year, }), ); // Mark first load done in an effect (not during render) to avoid a // render-phase ref mutation. useEffect(() => { if (!isPending) hasLoadedOnce.current = true; }, [isPending]); if (!hasPermission("orders.view")) return ; const handleDelete = async () => { if (!deleteConfirm.order) return; try { await deleteMutation.mutateAsync({ id: deleteConfirm.order.id }); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } }; // Only show the full-page skeleton on the very first load; on subsequent // refetches (filter/status/page change) keep the table visible (the Card // dims via isFetching) so it doesn't flash. if (isPending && !hasLoadedOnce.current) { return ; } const columns: DataColumn[] = [ { key: "po_number", header: "Číslo", width: "16%", sortKey: "po_number", mono: true, render: (o) => ( {documentNumberLabel(o.po_number)} ), }, { key: "supplier_name", header: "Dodavatel", width: "24%", render: (o) => o.supplier_name || "—", }, { key: "order_date", header: "Datum", width: "13%", sortKey: "order_date", mono: true, render: (o) => (o.order_date ? formatDate(o.order_date) : "—"), }, { key: "status", header: "Stav", width: "14%", sortKey: "status", render: (o) => ( ), }, { key: "total", header: "Celkem", width: "14%", align: "right", mono: true, bold: true, render: (o) => formatCurrency(o.total, o.currency ?? "CZK"), }, { key: "actions", header: "Akce", width: "14%", align: "right", render: (o) => { // Offers' semantics: read-only rows (completed/cancelled) get the // view icon, editable rows get the edit icon. const readOnly = o.status === "completed" || o.status === "cancelled"; return ( navigate(`/orders/issued/${o.id}`)} aria-label={readOnly ? "Zobrazit" : "Upravit"} title={readOnly ? "Zobrazit" : "Upravit"} > {readOnly ? ViewIcon : EditIcon} {/* Drafts have no number yet — /file 404s for them. */} {hasPermission("orders.view") && o.po_number && ( openPdf(o.id, `${API_BASE}/issued-orders/${o.id}/file`) } aria-label="Zobrazit objednávku" title="Zobrazit objednávku" disabled={pdfLoadingId === o.id} > {pdfLoadingId === o.id ? ( ) : ( PdfIcon )} )} {hasPermission("orders.delete") && ( setDeleteConfirm({ show: true, order: o })} aria-label="Smazat" title="Smazat" > {DeleteIcon} )} ); }, }, ]; // Per-status row tints — subtle channel-alpha washes (never a solid `.light` // fill, which is invisible-text in dark mode). Completed = success wash, // cancelled = faded/muted, matching the Offers/Invoices intensity. const rowSx = (o: IssuedOrder) => { if (o.status === "cancelled") { return { opacity: 0.6, "& td": { color: "var(--mui-palette-text-secondary)" }, }; } if (o.status === "completed") { return { backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)", "&:hover": { backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.18)", }, }; } return {}; }; // Search/status/supplier 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 || !!status || !!supplierFilter; const total = pagination?.total ?? orders.length; const countLine = `${total} ${czechPlural( total, "objednávka", "objednávky", "objednávek", )}`; return ( <> {countLine} { setSearch(e.target.value); setPage(1); }} placeholder="Hledat podle čísla nebo dodavatele..." fullWidth /> { setSupplierFilter(value ? Number(value) : ""); setPage(1); }} options={[ { value: "", label: "Všichni dodavatelé" }, ...(suppliers ?? []).map((s) => ({ value: String(s.id), label: s.name, })), ]} /> columns={columns} rows={orders} rowKey={(o) => o.id} rowSx={rowSx} sortBy={sort} sortDir={order} onSort={handleSort} empty={ isFiltered ? ( ) : ( Vytvořit objednávku ) : undefined } /> ) } /> {(statsQuery.data?.length ?? 0) > 0 && ( Celkem bez DPH: {formatMultiCurrency(statsQuery.data ?? [])} )} setDeleteConfirm({ show: false, order: null })} onConfirm={handleDelete} title="Smazat objednávku" message={ deleteConfirm.order ? `Opravdu chcete smazat objednávku „${documentNumberLabel(deleteConfirm.order.po_number)}“? Budou smazány i všechny položky. Tato akce je nevratná.` : "" } confirmText="Smazat" confirmVariant="danger" loading={deleteMutation.isPending} /> ); }