diff --git a/src/admin/pages/Offers.tsx b/src/admin/pages/Offers.tsx index 1bddcce..5a363b4 100644 --- a/src/admin/pages/Offers.tsx +++ b/src/admin/pages/Offers.tsx @@ -1,22 +1,42 @@ import { useState, useEffect, useRef } from "react"; +import { Link as RouterLink, useNavigate } from "react-router-dom"; +import { motion } from "framer-motion"; +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 { 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 useDebounce from "../hooks/useDebounce"; 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"; +import { + Button, + Card, + DataTable, + Pagination, + Modal, + ConfirmDialog, + Field, + TextField, + Select, + StatusChip, + FileUpload, + Alert, + EmptyState, + FilterBar, + Tabs, + PageHeader, + type DataColumn, + type TabDef, +} from "../ui"; const API_BASE = "/api/admin"; const DRAFT_KEY = "boha_offer_draft"; @@ -54,14 +74,160 @@ interface Draft { 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, activeSort } = - useTableSort("quotation_number"); + 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(""); @@ -117,7 +283,7 @@ export default function Offers() { isFetching, } = usePaginatedQuery( offerListOptions({ - search, + search: debouncedSearch, sort, order, page, @@ -173,17 +339,6 @@ export default function Offers() { 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) => { @@ -302,63 +457,272 @@ export default function Offers() { ); } + 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). Subtle, theme-token based so they stay dark/light-safe. + 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 { + bgcolor: "error.light", + opacity: 0.9, + "&:hover": { + bgcolor: "error.light", + filter: "brightness(0.97)", + }, + }; + } + if (isCompleted) { + return { + bgcolor: "success.light", + opacity: 0.85, + "&:hover": { + bgcolor: "success.light", + filter: "brightness(0.97)", + }, + }; + } + if (isExpired) { + return { + bgcolor: "warning.light", + opacity: 0.85, + "&:hover": { + bgcolor: "warning.light", + filter: "brightness(0.97)", + }, + }; + } + 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 - - )} -
+ + {hasPermission("settings.templates") && ( + + )} + {hasPermission("offers.create") && ( + + )} + + } + />
-
- {STATUS_FILTERS.map((f) => ( - - ))} -
+ + { + setStatusFilter(v); + setPage(1); + }} + tabs={tabs} + /> +
+ + + { + setSearch(e.target.value); + setPage(1); + }} + placeholder="Hledat podle čísla, projektu nebo zákazníka..." + fullWidth + /> + + + + { - 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 - - )} -
+ + + columns={columns} + rows={quotations} + rowKey={(q) => q.id} + rowSx={rowSx} + sortBy={sort} + sortDir={order} + onSort={handleSort} + empty={ + debouncedSearch ? ( + ) : ( -
- - - - - - - - - - - - - - - {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{" "} - - CelkemAkce
- - 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í. -
-
- )} -
-
- - + + Vytvořit první nabídku + + ) : undefined + } + /> + ) + } + /> + + - 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" + confirmVariant="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" + confirmVariant="danger" loading={invalidating} /> - setOrderModal({ show: false, quotation: null })} onSubmit={handleCreateOrder} title="Vytvořit objednávku" - submitLabel={creatingOrder ? "Vytváření..." : "Vytvořit"} + subtitle={`Nabídka: ${orderModal.quotation?.quotation_number ?? ""}`} + submitText="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 - -
-
-
- + + setCustomerOrderNumber(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && !creatingOrder && handleCreateOrder() + } + placeholder="Např. PO-2026-001" + autoFocus + /> + + + setOrderAttachment(files[0] ?? null)} + accept="application/pdf" + multiple={false} + /> + + Max 10 MB + + + + ); }