adds pagination

This commit is contained in:
Victor Broman 2026-02-07 07:47:20 +01:00
parent 7f1ea8ba72
commit aa3b63095c
12 changed files with 368 additions and 4 deletions

@ -9,18 +9,32 @@ import (
func (h *Handler) CustomerList(w http.ResponseWriter, r *http.Request) {
search := r.URL.Query().Get("search")
customers, err := models.CustomerGetAll(h.DB, search)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit := 10
customers, total, err := models.CustomerGetPaginated(h.DB, search, page, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + limit - 1) / limit
data := map[string]interface{}{
"Title": "Customers",
"Username": h.getUsername(r),
"ActivePage": "customers",
"Customers": customers,
"Search": search,
"Page": page,
"TotalPages": totalPages,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
// HTMX partial for search

@ -10,18 +10,32 @@ import (
func (h *Handler) InvoiceList(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
invoices, err := models.InvoiceGetAll(h.DB, status)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit := 10
invoices, total, err := models.InvoiceGetPaginated(h.DB, status, page, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + limit - 1) / limit
data := map[string]interface{}{
"Title": "Invoices",
"Username": h.getUsername(r),
"ActivePage": "invoices",
"Invoices": invoices,
"FilterStatus": status,
"Page": page,
"TotalPages": totalPages,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" {

@ -25,17 +25,31 @@ func (h *Handler) ChartOfAccounts(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) JournalEntries(w http.ResponseWriter, r *http.Request) {
entries, err := models.JournalEntryGetAll(h.DB)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit := 20
entries, total, err := models.JournalEntryGetPaginated(h.DB, page, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + limit - 1) / limit
data := map[string]interface{}{
"Title": "Journal Entries",
"Username": h.getUsername(r),
"ActivePage": "ledger",
"Entries": entries,
"Page": page,
"TotalPages": totalPages,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
h.render(w, []string{"layout.html", "ledger/journal_entries.html"}, data)
}

@ -11,18 +11,32 @@ import (
func (h *Handler) OrderList(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
orders, err := models.OrderGetAll(h.DB, status)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit := 10
orders, total, err := models.OrderGetPaginated(h.DB, status, page, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + limit - 1) / limit
data := map[string]interface{}{
"Title": "Orders",
"Username": h.getUsername(r),
"ActivePage": "orders",
"Orders": orders,
"FilterStatus": status,
"Page": page,
"TotalPages": totalPages,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" {

@ -84,3 +84,46 @@ func CustomerCount(db *sql.DB) int {
db.QueryRow("SELECT COUNT(*) FROM customers").Scan(&count)
return count
}
func CustomerGetPaginated(db *sql.DB, search string, page, limit int) ([]Customer, int, error) {
offset := (page - 1) * limit
// Base queries
query := "SELECT id, name, email, phone, address, created_at, updated_at FROM customers"
countQuery := "SELECT COUNT(*) FROM customers"
args := []interface{}{}
if search != "" {
where := " WHERE name LIKE ? OR email LIKE ?"
query += where
countQuery += where
s := "%" + search + "%"
args = append(args, s, s)
}
// Get total count first
var total int
if err := db.QueryRow(countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
// Add limit and offset to query
query += " ORDER BY name LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var customers []Customer
for rows.Next() {
var c Customer
if err := rows.Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Address, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, 0, err
}
customers = append(customers, c)
}
return customers, total, nil
}

@ -46,6 +46,48 @@ func InvoiceGetAll(db *sql.DB, status string) ([]Invoice, error) {
return invoices, nil
}
func InvoiceGetPaginated(db *sql.DB, status string, page, limit int) ([]Invoice, int, error) {
offset := (page - 1) * limit
// Base queries
query := `SELECT i.id, i.order_id, i.customer_id, c.name, i.invoice_number, i.status, i.amount, i.due_date, i.paid_date, i.created_at
FROM invoices i JOIN customers c ON i.customer_id = c.id`
countQuery := "SELECT COUNT(*) FROM invoices i"
args := []interface{}{}
if status != "" {
where := " WHERE i.status = ?"
query += where
countQuery += where
args = append(args, status)
}
// Get total count
var total int
if err := db.QueryRow(countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
query += " ORDER BY i.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var invoices []Invoice
for rows.Next() {
var inv Invoice
if err := rows.Scan(&inv.ID, &inv.OrderID, &inv.CustomerID, &inv.CustomerName, &inv.InvoiceNumber, &inv.Status, &inv.Amount, &inv.DueDate, &inv.PaidDate, &inv.CreatedAt); err != nil {
return nil, 0, err
}
invoices = append(invoices, inv)
}
return invoices, total, nil
}
func InvoiceGetByID(db *sql.DB, id int) (*Invoice, error) {
inv := &Invoice{}
err := db.QueryRow(

@ -103,6 +103,47 @@ func JournalEntryGetAll(db *sql.DB) ([]JournalEntry, error) {
return entries, nil
}
func JournalEntryGetPaginated(db *sql.DB, page, limit int) ([]JournalEntry, int, error) {
offset := (page - 1) * limit
var total int
if err := db.QueryRow("SELECT COUNT(*) FROM journal_entries").Scan(&total); err != nil {
return nil, 0, err
}
query := `
SELECT
je.id,
je.entry_date,
je.description,
je.reference,
je.created_at,
COALESCE(SUM(jl.debit), 0),
COALESCE(SUM(jl.credit), 0)
FROM journal_entries je
LEFT JOIN journal_lines jl ON je.id = jl.journal_entry_id
GROUP BY je.id
ORDER BY je.created_at DESC
LIMIT ? OFFSET ?
`
rows, err := db.Query(query, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var entries []JournalEntry
for rows.Next() {
var je JournalEntry
if err := rows.Scan(&je.ID, &je.EntryDate, &je.Description, &je.Reference, &je.CreatedAt, &je.TotalDebit, &je.TotalCredit); err != nil {
return nil, 0, err
}
entries = append(entries, je)
}
return entries, total, nil
}
func JournalEntryGetByID(db *sql.DB, id int) (*JournalEntry, error) {
je := &JournalEntry{}
err := db.QueryRow("SELECT id, entry_date, description, reference, created_at FROM journal_entries WHERE id = ?", id).

@ -55,6 +55,48 @@ func OrderGetAll(db *sql.DB, status string) ([]Order, error) {
return orders, nil
}
func OrderGetPaginated(db *sql.DB, status string, page, limit int) ([]Order, int, error) {
offset := (page - 1) * limit
// Base queries
query := `SELECT o.id, o.customer_id, c.name, o.status, o.order_date, o.total_amount, o.notes, o.created_at, o.updated_at
FROM orders o JOIN customers c ON o.customer_id = c.id`
countQuery := "SELECT COUNT(*) FROM orders o"
args := []interface{}{}
if status != "" {
where := " WHERE o.status = ?"
query += where
countQuery += where
args = append(args, status)
}
// Get total count
var total int
if err := db.QueryRow(countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
query += " ORDER BY o.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var orders []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.CustomerID, &o.CustomerName, &o.Status, &o.OrderDate, &o.TotalAmount, &o.Notes, &o.CreatedAt, &o.UpdatedAt); err != nil {
return nil, 0, err
}
orders = append(orders, o)
}
return orders, total, nil
}
func OrderGetByID(db *sql.DB, id int) (*Order, error) {
o := &Order{}
err := db.QueryRow(

@ -60,4 +60,40 @@
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Page <span class="font-medium">{{.Page}}</span> of <span class="font-medium">{{.TotalPages}}</span>
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{{if .HasPrev}}
<button
hx-get="/customers?page={{.PrevPage}}&search={{.Search}}&partial=true"
hx-target="#customer-table"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</button>
{{end}}
{{if .HasNext}}
<button
hx-get="/customers?page={{.NextPage}}&search={{.Search}}&partial=true"
hx-target="#customer-table"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Next</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
{{end}}
</nav>
</div>
</div>
</div>
{{end}}

@ -64,4 +64,40 @@
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Page <span class="font-medium">{{.Page}}</span> of <span class="font-medium">{{.TotalPages}}</span>
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{{if .HasPrev}}
<button
hx-get="/invoices?page={{.PrevPage}}&status={{.FilterStatus}}&partial=true"
hx-target="#invoice-table"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</button>
{{end}}
{{if .HasNext}}
<button
hx-get="/invoices?page={{.NextPage}}&status={{.FilterStatus}}&partial=true"
hx-target="#invoice-table"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Next</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
{{end}}
</nav>
</div>
</div>
</div>
{{end}}

@ -45,5 +45,37 @@
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Page <span class="font-medium">{{.Page}}</span> of <span class="font-medium">{{.TotalPages}}</span>
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{{if .HasPrev}}
<a href="/ledger/journal?page={{.PrevPage}}"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</a>
{{end}}
{{if .HasNext}}
<a href="/ledger/journal?page={{.NextPage}}"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Next</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</a>
{{end}}
</nav>
</div>
</div>
</div>
</div>
{{end}}

@ -66,4 +66,40 @@
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Page <span class="font-medium">{{.Page}}</span> of <span class="font-medium">{{.TotalPages}}</span>
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{{if .HasPrev}}
<button
hx-get="/orders?page={{.PrevPage}}&status={{.FilterStatus}}&partial=true"
hx-target="#order-table"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</button>
{{end}}
{{if .HasNext}}
<button
hx-get="/orders?page={{.NextPage}}&status={{.FilterStatus}}&partial=true"
hx-target="#order-table"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Next</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
{{end}}
</nav>
</div>
</div>
</div>
{{end}}