import { useState, useEffect, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link as RouterLink, useNavigate } from "react-router-dom"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate } from "../utils/formatters"; import useTableSort from "../hooks/useTableSort"; import useDebounce from "../hooks/useDebounce"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { orderListOptions, orderNextNumberOptions, } from "../lib/queries/orders"; import { offerCustomersOptions } from "../lib/queries/offers"; import { companySettingsOptions } from "../lib/queries/settings"; import { useApiMutation } from "../lib/queries/mutations"; import { Card, DataTable, Pagination, Modal, ConfirmDialog, Field, TextField, Select, StatusChip, CheckboxField, FileUpload, FilterBar, EmptyState, LoadingState, type DataColumn, } from "../ui"; import { ORDER_STATUS, statusLabel, statusColor, statusOptions, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; const STATUS_OPTIONS = statusOptions(ORDER_STATUS, { value: "", label: "Všechny stavy", }); interface Order { id: number; order_number: string; quotation_id: number; quotation_number: string; customer_name: string; status: string; created_at: string; total: number; currency: string; invoice_id?: number; } const ViewIcon = ( ); const InvoiceViewIcon = ( F ); const InvoiceCreateIcon = ( ); const DeleteIcon = ( ); interface OrdersReceivedProps { month: number; year: number; createOpen: boolean; setCreateOpen: (open: boolean) => void; } export default function OrdersReceived({ month, year, createOpen, setCreateOpen, }: OrdersReceivedProps) { const alert = useAlert(); const { hasPermission } = useAuth(); const { sort, order, handleSort } = useTableSort("order_number"); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search, 300); const [status, setStatus] = useState(""); const [page, setPage] = useState(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 [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; order: Order | null; }>({ show: false, order: null }); const [deleteFiles, setDeleteFiles] = useState(false); const deleteMutation = useApiMutation< { id: number; delete_files: boolean }, { message?: string; error?: string } >({ url: ({ id }) => `${API_BASE}/orders/${id}`, method: () => "DELETE", invalidate: ["orders", "offers", "projects", "invoices"], onSuccess: (data) => { setDeleteConfirm({ show: false, order: null }); setDeleteFiles(false); alert.success(data?.message || "Objednávka byla smazána"); }, }); const [createForm, setCreateForm] = useState({ customer_id: "", customer_order_number: "", currency: "CZK", vat_rate: "21", apply_vat: true, scope_title: "", scope_description: "", notes: "", create_project: true, price: "", quantity: "1", }); const [creating, setCreating] = useState(false); const queryClient = useQueryClient(); const navigate = useNavigate(); const [orderAttachment, setOrderAttachment] = useState(null); const customersQuery = useQuery({ ...offerCustomersOptions(), enabled: createOpen, }); const nextNumberQuery = useQuery({ ...orderNextNumberOptions(), enabled: createOpen, }); const companySettings = useQuery(companySettingsOptions()).data; // Configurable currency list from company settings (falls back to the // built-in list when settings are empty) — matches Offers/Invoices. const currencyOptions = ( companySettings?.available_currencies || ["CZK", "EUR", "USD", "GBP"] ).map((c) => ({ value: c, label: c })); const closeCreate = () => { setCreateOpen(false); setCreateForm({ customer_id: "", customer_order_number: "", currency: "CZK", vat_rate: "21", apply_vat: true, scope_title: "", scope_description: "", notes: "", create_project: true, price: "", quantity: "1", }); setOrderAttachment(null); }; const handleCreate = async () => { setCreating(true); try { const priceNum = createForm.price ? Number(createForm.price) : 0; const qtyNum = createForm.quantity ? Number(createForm.quantity) : 1; const items = priceNum > 0 ? [ { description: createForm.scope_title || "Položka", quantity: qtyNum, unit_price: priceNum, is_included_in_total: true, }, ] : undefined; let fetchOptions: RequestInit; if (orderAttachment) { const fd = new FormData(); if (createForm.customer_id) fd.append("customer_id", createForm.customer_id); fd.append("customer_order_number", createForm.customer_order_number); fd.append("currency", createForm.currency); fd.append("vat_rate", createForm.vat_rate); fd.append("apply_vat", createForm.apply_vat ? "1" : "0"); fd.append("scope_title", createForm.scope_title); fd.append("scope_description", createForm.scope_description); fd.append("notes", createForm.notes); fd.append("create_project", createForm.create_project ? "1" : "0"); if (items) fd.append("items", JSON.stringify(items)); fd.append("attachment", orderAttachment); fetchOptions = { method: "POST", body: fd }; } else { fetchOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customer_id: createForm.customer_id ? Number(createForm.customer_id) : null, customer_order_number: createForm.customer_order_number, currency: createForm.currency, vat_rate: createForm.vat_rate, apply_vat: createForm.apply_vat, scope_title: createForm.scope_title, scope_description: createForm.scope_description, notes: createForm.notes, create_project: createForm.create_project, ...(items ? { items } : {}), }), }; } const response = await apiFetch(`${API_BASE}/orders`, fetchOptions); const result = await response.json(); if (result.success) { closeCreate(); alert.success(result.message || "Objednávka byla vytvořena"); await queryClient.invalidateQueries({ queryKey: ["orders"] }); await queryClient.invalidateQueries({ queryKey: ["projects"] }); await queryClient.invalidateQueries({ queryKey: ["offers"] }); await queryClient.invalidateQueries({ queryKey: ["invoices"] }); if (result.data?.id) navigate(`/orders/${result.data.id}`); } else { alert.error(result.error || "Nepodařilo se vytvořit objednávku"); } } catch { alert.error("Chyba připojení"); } finally { setCreating(false); } }; const { items: orders, pagination, isPending, isFetching, } = usePaginatedQuery( orderListOptions({ search: debouncedSearch, sort, order, page, status: status || 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, delete_files: deleteFiles, }); } 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: "order_number", header: "Číslo", width: "14%", sortKey: "order_number", mono: true, render: (o) => ( {o.order_number} ), }, { key: "quotation", header: "Nabídka", width: "14%", render: (o) => ( {o.quotation_number} ), }, { key: "customer", header: "Zákazník", width: "20%", render: (o) => o.customer_name || "—", }, { key: "status", header: "Stav", width: "13%", sortKey: "status", render: (o) => ( ), }, { key: "created_at", header: "Datum", width: "12%", sortKey: "created_at", mono: true, render: (o) => formatDate(o.created_at), }, { key: "total", header: "Celkem", width: "13%", align: "right", mono: true, bold: true, render: (o) => formatCurrency(o.total, o.currency), }, { key: "actions", header: "Akce", width: "14%", align: "right", render: (o) => ( navigate(`/orders/${o.id}`)} aria-label="Detail" title="Detail" > {ViewIcon} {o.invoice_id ? ( navigate(`/invoices/${o.invoice_id}`)} aria-label="Zobrazit fakturu" title="Zobrazit fakturu" > {InvoiceViewIcon} ) : ( hasPermission("invoices.create") && ( navigate(`/invoices/new?fromOrder=${o.id}`)} aria-label="Vytvořit fakturu" title="Vytvořit fakturu" > {InvoiceCreateIcon} ) )} {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 (stornovana) = faded/muted, matching the Offers/Invoices intensity. const rowSx = (o: Order) => { if (o.status === "stornovana") { return { opacity: 0.6, "& td": { color: "var(--mui-palette-text-secondary)" }, }; } if (o.status === "dokoncena") { return { backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)", "&:hover": { backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.18)", }, }; } return {}; }; // Search/status filter is active → an empty list means "nothing matches" // (no create hint); a genuinely empty list keeps the explanatory empty state. const isFiltered = !!debouncedSearch || !!status; return ( <> { setSearch(e.target.value); setPage(1); }} placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..." fullWidth /> setCreateForm({ ...createForm, customer_id: value }) } options={[ { value: "", label: "— bez zákazníka —" }, ...(customersQuery.data ?? []).map((c) => ({ value: String(c.id), label: c.name, })), ]} /> setCreateForm({ ...createForm, customer_order_number: e.target.value, }) } />