diff --git a/api/admin/handlers/invoices-handlers.php b/api/admin/handlers/invoices-handlers.php index 95acce5..d566bc1 100644 --- a/api/admin/handlers/invoices-handlers.php +++ b/api/admin/handlers/invoices-handlers.php @@ -169,12 +169,7 @@ function handleGetStats(PDO $pdo): void function handleGetList(PDO $pdo): void { - $search = trim($_GET['search'] ?? ''); $statusFilter = trim($_GET['status'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - $page = max(1, (int) ($_GET['page'] ?? 1)); - $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); $sortMap = [ 'InvoiceNumber' => 'i.invoice_number', @@ -188,10 +183,8 @@ function handleGetList(PDO $pdo): void 'IssueDate' => 'i.issue_date', 'issue_date' => 'i.issue_date', ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; + + $p = PaginationHelper::parseParams($sortMap); // Lazy overdue detekce $pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()"); @@ -199,10 +192,9 @@ function handleGetList(PDO $pdo): void $where = 'WHERE 1=1'; $params = []; - if ($search) { - $search = mb_substr($search, 0, 100); + if ($p['search']) { $where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)'; - $searchParam = "%{$search}%"; + $searchParam = "%{$p['search']}%"; $params = array_merge($params, [$searchParam, $searchParam, $searchParam]); } @@ -215,36 +207,26 @@ function handleGetList(PDO $pdo): void } } - $countSql = " - SELECT COUNT(*) - FROM invoices i + $from = "FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id - $where - "; - $stmt = $pdo->prepare($countSql); - $stmt->execute($params); - $total = (int) $stmt->fetchColumn(); + LEFT JOIN orders o ON i.order_id = o.id"; - $offset = ($page - 1) * $perPage; - - $sql = " - SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency, + $result = PaginationHelper::paginate( + $pdo, + "SELECT COUNT(*) FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id {$where}", + "SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency, i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat, c.name as customer_name, (SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0) FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal, o.order_number - FROM invoices i - LEFT JOIN customers c ON i.customer_id = c.id - LEFT JOIN orders o ON i.order_id = o.id - $where - ORDER BY $sortCol $order - LIMIT $perPage OFFSET $offset - "; + {$from} {$where} + ORDER BY {$p['sort']} {$p['order']}", + $params, + $p + ); - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $invoices = $stmt->fetchAll(); + $invoices = $result['items']; // Dopocitat celkovou castku s DPH foreach ($invoices as &$inv) { @@ -265,9 +247,7 @@ function handleGetList(PDO $pdo): void successResponse([ 'invoices' => $invoices, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, + 'pagination' => $result['pagination'], ]); } diff --git a/api/admin/handlers/offers-handlers.php b/api/admin/handlers/offers-handlers.php index 703f012..473b0d2 100644 --- a/api/admin/handlers/offers-handlers.php +++ b/api/admin/handlers/offers-handlers.php @@ -4,12 +4,6 @@ declare(strict_types=1); function handleGetList(PDO $pdo): void { - $search = trim($_GET['search'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - $page = max(1, (int) ($_GET['page'] ?? 1)); - $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); - $sortMap = [ 'Date' => 'q.created_at', 'CreatedAt' => 'q.created_at', @@ -23,57 +17,37 @@ function handleGetList(PDO $pdo): void 'Currency' => 'q.currency', 'currency' => 'q.currency', ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; + $p = PaginationHelper::parseParams($sortMap); $where = 'WHERE 1=1'; $params = []; - if ($search) { - $search = mb_substr($search, 0, 100); + if ($p['search']) { $where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; - $searchParam = "%{$search}%"; + $searchParam = "%{$p['search']}%"; $params = [$searchParam, $searchParam, $searchParam]; } - // Celkovy pocet pro pagination - $countSql = " - SELECT COUNT(*) - FROM quotations q - LEFT JOIN customers c ON q.customer_id = c.id - $where - "; - $stmt = $pdo->prepare($countSql); - $stmt->execute($params); - $total = (int) $stmt->fetchColumn(); + $from = "FROM quotations q LEFT JOIN customers c ON q.customer_id = c.id"; - $offset = ($page - 1) * $perPage; - - $sql = " - SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until, + $result = PaginationHelper::paginate( + $pdo, + "SELECT COUNT(*) {$from} {$where}", + "SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until, q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate, q.customer_id, q.order_id, q.status, c.name as customer_name, (SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0) FROM quotation_items qi WHERE qi.quotation_id = q.id) as total - FROM quotations q - LEFT JOIN customers c ON q.customer_id = c.id - $where - ORDER BY $sortCol $order - LIMIT $perPage OFFSET $offset - "; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $quotations = $stmt->fetchAll(); + {$from} {$where} + ORDER BY {$p['sort']} {$p['order']}", + $params, + $p + ); successResponse([ - 'quotations' => $quotations, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, + 'quotations' => $result['items'], + 'pagination' => $result['pagination'], ]); } diff --git a/api/admin/handlers/orders-handlers.php b/api/admin/handlers/orders-handlers.php index ad4836e..345ccf3 100644 --- a/api/admin/handlers/orders-handlers.php +++ b/api/admin/handlers/orders-handlers.php @@ -25,12 +25,6 @@ function generateOrderNumber(PDO $pdo): string function handleGetList(PDO $pdo): void { - $search = trim($_GET['search'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - $page = max(1, (int) ($_GET['page'] ?? 1)); - $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); - $sortMap = [ 'OrderNumber' => 'o.order_number', 'order_number' => 'o.order_number', @@ -41,36 +35,25 @@ function handleGetList(PDO $pdo): void 'Currency' => 'o.currency', 'currency' => 'o.currency', ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; + $p = PaginationHelper::parseParams($sortMap); $where = 'WHERE 1=1'; $params = []; - if ($search) { - $search = mb_substr($search, 0, 100); + if ($p['search']) { $where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; - $searchParam = "%{$search}%"; + $searchParam = "%{$p['search']}%"; $params = [$searchParam, $searchParam, $searchParam, $searchParam]; } - $countSql = " - SELECT COUNT(*) - FROM orders o + $from = "FROM orders o LEFT JOIN quotations q ON o.quotation_id = q.id - LEFT JOIN customers c ON o.customer_id = c.id - $where - "; - $stmt = $pdo->prepare($countSql); - $stmt->execute($params); - $total = (int) $stmt->fetchColumn(); + LEFT JOIN customers c ON o.customer_id = c.id"; - $offset = ($page - 1) * $perPage; - - $sql = " - SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency, + $result = PaginationHelper::paginate( + $pdo, + "SELECT COUNT(*) {$from} {$where}", + "SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency, o.created_at, o.apply_vat, o.vat_rate, q.quotation_number, q.project_code, c.name as customer_name, @@ -78,23 +61,15 @@ function handleGetList(PDO $pdo): void FROM order_items oi WHERE oi.order_id = o.id) as total, (SELECT inv.id FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_id, (SELECT inv.invoice_number FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_number - FROM orders o - LEFT JOIN quotations q ON o.quotation_id = q.id - LEFT JOIN customers c ON o.customer_id = c.id - $where - ORDER BY $sortCol $order - LIMIT $perPage OFFSET $offset - "; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $orders = $stmt->fetchAll(); + {$from} {$where} + ORDER BY {$p['sort']} {$p['order']}", + $params, + $p + ); successResponse([ - 'orders' => $orders, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, + 'orders' => $result['items'], + 'pagination' => $result['pagination'], ]); } diff --git a/api/admin/handlers/projects-handlers.php b/api/admin/handlers/projects-handlers.php index 05063dc..e1dd964 100644 --- a/api/admin/handlers/projects-handlers.php +++ b/api/admin/handlers/projects-handlers.php @@ -156,12 +156,6 @@ function handleDeleteProject(PDO $pdo, int $id): void function handleGetList(PDO $pdo): void { - $search = trim($_GET['search'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - $page = max(1, (int) ($_GET['page'] ?? 1)); - $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); - $sortMap = [ 'ProjectNumber' => 'p.project_number', 'project_number' => 'p.project_number', @@ -176,56 +170,37 @@ function handleGetList(PDO $pdo): void 'CreatedAt' => 'p.created_at', 'created_at' => 'p.created_at', ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; + $p = PaginationHelper::parseParams($sortMap); $where = 'WHERE 1=1'; $params = []; - if ($search) { - $search = mb_substr($search, 0, 100); + if ($p['search']) { $where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)'; - $searchParam = "%{$search}%"; + $searchParam = "%{$p['search']}%"; $params = [$searchParam, $searchParam, $searchParam]; } - $countSql = " - SELECT COUNT(*) - FROM projects p + $from = "FROM projects p LEFT JOIN customers c ON p.customer_id = c.id - LEFT JOIN orders o ON p.order_id = o.id - $where - "; - $stmt = $pdo->prepare($countSql); - $stmt->execute($params); - $total = (int) $stmt->fetchColumn(); + LEFT JOIN orders o ON p.order_id = o.id"; - $offset = ($page - 1) * $perPage; - - $sql = " - SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date, + $result = PaginationHelper::paginate( + $pdo, + "SELECT COUNT(*) {$from} {$where}", + "SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date, p.order_id, p.quotation_id, p.created_at, c.name as customer_name, o.order_number - FROM projects p - LEFT JOIN customers c ON p.customer_id = c.id - LEFT JOIN orders o ON p.order_id = o.id - $where - ORDER BY $sortCol $order - LIMIT $perPage OFFSET $offset - "; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $projects = $stmt->fetchAll(); + {$from} {$where} + ORDER BY {$p['sort']} {$p['order']}", + $params, + $p + ); successResponse([ - 'projects' => $projects, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, + 'projects' => $result['items'], + 'pagination' => $result['pagination'], ]); } diff --git a/api/admin/invoices.php b/api/admin/invoices.php index 7df0b63..73c7f18 100644 --- a/api/admin/invoices.php +++ b/api/admin/invoices.php @@ -19,6 +19,7 @@ require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/CnbRates.php'; +require_once dirname(__DIR__) . '/includes/PaginationHelper.php'; require_once __DIR__ . '/handlers/invoices-handlers.php'; setCorsHeaders(); diff --git a/api/admin/offers.php b/api/admin/offers.php index e08b041..3a9ce7d 100644 --- a/api/admin/offers.php +++ b/api/admin/offers.php @@ -18,6 +18,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once dirname(__DIR__) . '/includes/PaginationHelper.php'; require_once __DIR__ . '/handlers/offers-handlers.php'; setCorsHeaders(); diff --git a/api/admin/orders.php b/api/admin/orders.php index 09318c6..62a2039 100644 --- a/api/admin/orders.php +++ b/api/admin/orders.php @@ -15,6 +15,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once dirname(__DIR__) . '/includes/PaginationHelper.php'; require_once __DIR__ . '/handlers/orders-handlers.php'; setCorsHeaders(); diff --git a/api/admin/projects.php b/api/admin/projects.php index dd061ed..0fb8de4 100644 --- a/api/admin/projects.php +++ b/api/admin/projects.php @@ -18,6 +18,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once dirname(__DIR__) . '/includes/PaginationHelper.php'; require_once __DIR__ . '/handlers/projects-handlers.php'; setCorsHeaders(); diff --git a/api/includes/PaginationHelper.php b/api/includes/PaginationHelper.php new file mode 100644 index 0000000..90d4519 --- /dev/null +++ b/api/includes/PaginationHelper.php @@ -0,0 +1,82 @@ + $page, + 'per_page' => $perPage, + 'sort' => $sortMap[$sort], + 'order' => $order, + 'search' => $search ? mb_substr($search, 0, 100) : '', + ]; + } + + /** + * Spusti COUNT + SELECT dotazy s pagination a vrati vysledek. + * + * @param PDO $pdo + * @param string $countSql - COUNT(*) dotaz + * @param string $dataSql - SELECT dotaz (bez LIMIT/OFFSET) + * @param array $params - parametry pro prepared statement + * @param array{page: int, per_page: int, sort: string, order: string} $pagination + * @return array{items: array, pagination: array} + */ + public static function paginate( + PDO $pdo, + string $countSql, + string $dataSql, + array $params, + array $pagination + ): array { + $page = $pagination['page']; + $perPage = $pagination['per_page']; + + $stmt = $pdo->prepare($countSql); + $stmt->execute($params); + $total = (int) $stmt->fetchColumn(); + + $offset = ($page - 1) * $perPage; + $totalPages = (int) ceil($total / $perPage); + + $sql = "{$dataSql} LIMIT {$perPage} OFFSET {$offset}"; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $items = $stmt->fetchAll(); + + return [ + 'items' => $items, + 'pagination' => [ + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + ], + ]; + } +} diff --git a/src/admin/admin.css b/src/admin/admin.css index 1e3fa2d..aad68d5 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -2332,6 +2332,93 @@ img { cursor: grabbing; } +/* ============================================================================ + Pagination + ============================================================================ */ + +.admin-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.75rem 0; + font-size: 13px; +} + +.admin-pagination-info { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; + white-space: nowrap; +} + +.admin-pagination-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.admin-pagination-page { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 6px; + border: 1px solid transparent; + border-radius: var(--border-radius-sm); + background: none; + color: var(--text-secondary); + font-size: 13px; + font-family: var(--font-mono); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.admin-pagination-page:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.admin-pagination-page.active { + background: var(--accent-color); + color: #fff; + border-color: var(--accent-color); + font-weight: 600; +} + +.admin-pagination-ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + color: var(--text-muted); + font-size: 14px; +} + +.admin-pagination-select { + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; +} + +@media (max-width: 640px) { + .admin-pagination { + flex-wrap: wrap; + gap: 0.5rem; + } + + .admin-pagination-info { + order: 2; + width: 100%; + text-align: center; + } +} + /* Keyboard shortcut badge */ .admin-kbd { display: inline-block; diff --git a/src/admin/components/Pagination.jsx b/src/admin/components/Pagination.jsx new file mode 100644 index 0000000..a642c91 --- /dev/null +++ b/src/admin/components/Pagination.jsx @@ -0,0 +1,106 @@ +import { useMemo } from 'react' + +/** + * Strankovaci komponenta pro seznamove stranky. + * + * @param {object} pagination - {total, page, per_page, total_pages} + * @param {function} onPageChange - callback(newPage) + * @param {function} [onPerPageChange] - callback(newPerPage) + */ +export default function Pagination({ pagination, onPageChange, onPerPageChange }) { + const page = pagination?.page ?? 1 + const totalPages = pagination?.total_pages ?? 1 + const total = pagination?.total ?? 0 + const perPage = pagination?.per_page ?? 25 + + const visiblePages = useMemo(() => { + const pages = [] + const maxVisible = 5 + let start = Math.max(1, page - Math.floor(maxVisible / 2)) + const end = Math.min(totalPages, start + maxVisible - 1) + + if (end - start < maxVisible - 1) { + start = Math.max(1, end - maxVisible + 1) + } + + if (start > 1) { + pages.push(1) + if (start > 2) { pages.push('...') } + } + + for (let i = start; i <= end; i++) { + pages.push(i) + } + + if (end < totalPages) { + if (end < totalPages - 1) { pages.push('...') } + pages.push(totalPages) + } + + return pages + }, [page, totalPages]) + + if (!pagination || totalPages <= 1) { return null } + + const from = (page - 1) * perPage + 1 + const to = Math.min(page * perPage, total) + + return ( +
- {invoices.length} {czechPlural(invoices.length, 'faktura', 'faktury', 'faktur')} + {pagination?.total ?? invoices.length} {czechPlural(pagination?.total ?? invoices.length, 'faktura', 'faktury', 'faktur')}
- {quotations.length} {czechPlural(quotations.length, 'nabídka', 'nabídky', 'nabídek')} + {pagination?.total ?? quotations.length} {czechPlural(pagination?.total ?? quotations.length, 'nabídka', 'nabídky', 'nabídek')}
- {orders.length} {czechPlural(orders.length, 'objednávka', 'objednávky', 'objednávek')} + {pagination?.total ?? orders.length} {czechPlural(pagination?.total ?? orders.length, 'objednávka', 'objednávky', 'objednávek')}
- {projects.length} {czechPlural(projects.length, 'projekt', 'projekty', 'projektů')} + {pagination?.total ?? projects.length} {czechPlural(pagination?.total ?? projects.length, 'projekt', 'projekty', 'projektů')}