feat: P2 strankovani - PaginationHelper, Pagination komponenta, integrace do 4 modulu
- PaginationHelper.php: parseParams() + paginate() - DRY backend pagination logika - Pagination.jsx: frontend strankovaci komponenta (prev/next/cisla/info) - CSS: .admin-pagination styly v admin.css - Refaktor handleru: offers, orders, invoices, projects pouzivaji PaginationHelper - Default 25 zaznamu na stranku (misto 500), max 500 - Frontend: page state + reset na search/filter zmenu - useListData: pagination data v response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
106
src/admin/components/Pagination.jsx
Normal file
106
src/admin/components/Pagination.jsx
Normal file
@@ -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 (
|
||||
<div className="admin-pagination">
|
||||
<span className="admin-pagination-info">
|
||||
{from}–{to} z {total}
|
||||
</span>
|
||||
|
||||
<div className="admin-pagination-controls">
|
||||
<button
|
||||
className="admin-btn-secondary admin-btn-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="Předchozí stránka"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{visiblePages.map((p, i) => (
|
||||
p === '...'
|
||||
? <span key={`ellipsis-${i}`} className="admin-pagination-ellipsis">…</span>
|
||||
: (
|
||||
<button
|
||||
key={p}
|
||||
className={`admin-pagination-page${p === page ? ' active' : ''}`}
|
||||
onClick={() => onPageChange(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
|
||||
<button
|
||||
className="admin-btn-secondary admin-btn-sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="Další stránka"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{onPerPageChange && (
|
||||
<select
|
||||
className="admin-pagination-select"
|
||||
value={perPage}
|
||||
onChange={(e) => onPerPageChange(Number(e.target.value))}
|
||||
aria-label="Záznamů na stránku"
|
||||
>
|
||||
{[10, 25, 50, 100].map((n) => (
|
||||
<option key={n} value={n}>{n} / strana</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import Pagination from '../components/Pagination'
|
||||
|
||||
const ReceivedInvoices = lazy(() => import('./ReceivedInvoices'))
|
||||
const API_BASE = '/api/admin'
|
||||
@@ -65,6 +66,7 @@ export default function Invoices() {
|
||||
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false)
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('invoice_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
const now = new Date()
|
||||
@@ -139,8 +141,8 @@ export default function Invoices() {
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const { items: invoices, loading, refetch: fetchData } = useListData('invoices.php', {
|
||||
dataKey: 'invoices', search, sort, order,
|
||||
const { items: invoices, loading, pagination, refetch: fetchData } = useListData('invoices.php', {
|
||||
dataKey: 'invoices', search, sort, order, page,
|
||||
extraParams: statusFilter ? { status: statusFilter } : {},
|
||||
errorMsg: 'Nepodařilo se načíst faktury'
|
||||
})
|
||||
@@ -269,7 +271,7 @@ export default function Invoices() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Faktury</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{invoices.length} {czechPlural(invoices.length, 'faktura', 'faktury', 'faktur')}
|
||||
{pagination?.total ?? invoices.length} {czechPlural(pagination?.total ?? invoices.length, 'faktura', 'faktury', 'faktur')}
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission('invoices.create') && (
|
||||
@@ -430,7 +432,7 @@ export default function Invoices() {
|
||||
<button
|
||||
key={f.value}
|
||||
className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`}
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
onClick={() => { setStatusFilter(f.value); setPage(1) }}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
@@ -450,7 +452,7 @@ export default function Invoices() {
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..."
|
||||
/>
|
||||
@@ -622,6 +624,7 @@ export default function Invoices() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import Pagination from '../components/Pagination'
|
||||
const API_BASE = '/api/admin'
|
||||
const DRAFT_KEY = 'boha_offer_draft'
|
||||
|
||||
@@ -22,6 +23,7 @@ export default function Offers() {
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('quotation_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, quotation: null })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
@@ -36,8 +38,8 @@ export default function Offers() {
|
||||
const [orderAttachment, setOrderAttachment] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
|
||||
const { items: quotations, loading, refetch: fetchData } = useListData('offers.php', {
|
||||
dataKey: 'quotations', search, sort, order,
|
||||
const { items: quotations, loading, pagination, refetch: fetchData } = useListData('offers.php', {
|
||||
dataKey: 'quotations', search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst nabídky'
|
||||
})
|
||||
|
||||
@@ -232,7 +234,7 @@ export default function Offers() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Nabídky</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{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')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
@@ -268,7 +270,7 @@ export default function Offers() {
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, projektu nebo zákazníka..."
|
||||
/>
|
||||
@@ -509,6 +511,7 @@ export default function Offers() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import Pagination from '../components/Pagination'
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
@@ -33,12 +34,13 @@ export default function Orders() {
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('order_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, order: null })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const { items: orders, loading, refetch: fetchData } = useListData('orders.php', {
|
||||
dataKey: 'orders', search, sort, order,
|
||||
const { items: orders, loading, pagination, refetch: fetchData } = useListData('orders.php', {
|
||||
dataKey: 'orders', search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst objednávky'
|
||||
})
|
||||
|
||||
@@ -137,7 +139,7 @@ export default function Orders() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Objednávky</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{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')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -153,7 +155,7 @@ export default function Orders() {
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
|
||||
/>
|
||||
@@ -264,6 +266,7 @@ export default function Orders() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import Pagination from '../components/Pagination'
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
@@ -31,11 +32,12 @@ export default function Projects() {
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('project_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [deletingId, setDeletingId] = useState(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
|
||||
const { items: projects, setItems: setProjects, loading } = useListData('projects.php', {
|
||||
dataKey: 'projects', search, sort, order,
|
||||
const { items: projects, setItems: setProjects, loading, pagination } = useListData('projects.php', {
|
||||
dataKey: 'projects', search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst projekty'
|
||||
})
|
||||
|
||||
@@ -132,7 +134,7 @@ export default function Projects() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Projekty</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{projects.length} {czechPlural(projects.length, 'projekt', 'projekty', 'projektů')}
|
||||
{pagination?.total ?? projects.length} {czechPlural(pagination?.total ?? projects.length, 'projekt', 'projekty', 'projektů')}
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission('projects.create') && (
|
||||
@@ -157,7 +159,7 @@ export default function Projects() {
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
||||
/>
|
||||
@@ -258,6 +260,7 @@ export default function Projects() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user