diff --git a/internal/db/repository.go b/internal/db/repository.go index c5c0fcf..ae743e8 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -10,6 +10,7 @@ import ( // ArtikalRepository definiše operacije nad artiklima type ArtikalRepository interface { Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error) + PrebrojiPoFilteru(ctx context.Context, filter ArtikalFilter) (int, error) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) Izmeni(ctx context.Context, a *model.Artikal) error @@ -78,6 +79,8 @@ type ArtikalFilter struct { Pretraga string KategorijaID *int64 SamoKriticni bool + Limit int + Offset int } // NabavkaRepository definiše operacije nad nabavkama @@ -99,9 +102,18 @@ type DobavljacRepository interface { Obrisi(ctx context.Context, id int64) error } +// KlijentFilter definiše parametre za filtriranje liste klijenata +type KlijentFilter struct { + Pretraga string + Limit int + Offset int +} + // KlijentRepository definiše operacije nad klijentima type KlijentRepository interface { Lista(ctx context.Context, pretraga string) ([]model.Klijent, error) + ListaFilter(ctx context.Context, filter KlijentFilter) ([]model.Klijent, error) + PrebrojiPoFilteru(ctx context.Context, filter KlijentFilter) (int, error) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) Izmeni(ctx context.Context, k *model.Klijent) error diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index ce3e5f0..694a862 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -50,6 +50,15 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod upit += " ORDER BY a.naziv ASC" + if filter.Limit > 0 { + upit += " LIMIT ?" + args = append(args, filter.Limit) + if filter.Offset > 0 { + upit += " OFFSET ?" + args = append(args, filter.Offset) + } + } + redovi, err := r.db.QueryContext(ctx, upit, args...) if err != nil { return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: %w", err) @@ -258,3 +267,36 @@ func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, nova return tx.Commit() } + +// PrebrojiPoFilteru vraća ukupan broj artikala koji zadovoljavaju filter (bez LIMIT/OFFSET) +func (r *ArtikalRepo) PrebrojiPoFilteru(ctx context.Context, filter db.ArtikalFilter) (int, error) { + upit := ` + SELECT COUNT(*) + FROM artikli a + LEFT JOIN kategorije k ON a.kategorija_id = k.id + WHERE 1=1` + + args := []any{} + + if filter.Pretraga != "" { + upit += " AND (a.naziv LIKE ? OR a.sifra LIKE ? OR a.barkod LIKE ? OR a.lokacija LIKE ? OR k.naziv LIKE ?)" + t := "%" + filter.Pretraga + "%" + args = append(args, t, t, t, t, t) + } + + if filter.KategorijaID != nil { + upit += " AND a.kategorija_id = ?" + args = append(args, *filter.KategorijaID) + } + + if filter.SamoKriticni { + upit += " AND a.kolicina <= a.kolicina_min" + } + + var broj int + if err := r.db.QueryRowContext(ctx, upit, args...).Scan(&broj); err != nil { + return 0, fmt.Errorf("ntech: ArtikalRepo.PrebrojiPoFilteru: %w", err) + } + + return broj, nil +} diff --git a/internal/db/sqlite/klijent.go b/internal/db/sqlite/klijent.go index 88e8b61..5b43251 100644 --- a/internal/db/sqlite/klijent.go +++ b/internal/db/sqlite/klijent.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" + "ntech/internal/db" "ntech/internal/model" ) @@ -28,9 +29,9 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije args := []any{} if pretraga != "" { - upit += " AND (ime LIKE ? OR prezime LIKE ? OR naziv_firme LIKE ?)" - p := "%" + pretraga + "%" - args = append(args, p, p, p) +upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)" + p := "%" + pretraga + "%" + args = append(args, p, p, p, p, p, p) } upit += " ORDER BY datum_unosa DESC" @@ -138,6 +139,81 @@ func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error { return nil } +// ListaFilter vraća listu klijenata sa limitom i offsetom (paginacija) +func (r *KlijentRepo) ListaFilter(ctx context.Context, filter db.KlijentFilter) ([]model.Klijent, error) { + upit := ` + SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti + WHERE 1=1` + + args := []any{} + + if filter.Pretraga != "" { + upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)" + p := "%" + filter.Pretraga + "%" + args = append(args, p, p, p, p, p, p) + } + + upit += " ORDER BY datum_unosa DESC" + + if filter.Limit > 0 { + upit += " LIMIT ?" + args = append(args, filter.Limit) + if filter.Offset > 0 { + upit += " OFFSET ?" + args = append(args, filter.Offset) + } + } + + redovi, err := r.db.QueryContext(ctx, upit, args...) + if err != nil { + return nil, fmt.Errorf("ntech: KlijentRepo.ListaFilter: %w", err) + } + defer redovi.Close() + + var rezultat []model.Klijent + for redovi.Next() { + var k model.Klijent + var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString + err := redovi.Scan( + &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa, + ) + if err != nil { + return nil, fmt.Errorf("ntech: KlijentRepo.ListaFilter: scan: %w", err) + } + k.Ime = ime.String + k.Prezime = prezime.String + k.JMBG = jmbg.String + k.NazivFirme = nazivFirme.String + k.PIB = pib.String + k.Telefon = telefon.String + k.Email = email.String + k.Mesto = mesto.String + k.Napomena = napomena.String + rezultat = append(rezultat, k) + } + + return rezultat, nil +} + +// PrebrojiPoFilteru vraća broj klijenata koji zadovoljavaju filter +func (r *KlijentRepo) PrebrojiPoFilteru(ctx context.Context, filter db.KlijentFilter) (int, error) { + upit := `SELECT COUNT(*) FROM klijenti WHERE 1=1` + args := []any{} + + if filter.Pretraga != "" { + upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)" + p := "%" + filter.Pretraga + "%" + args = append(args, p, p, p, p, p, p) + } + + var broj int + if err := r.db.QueryRowContext(ctx, upit, args...).Scan(&broj); err != nil { + return 0, fmt.Errorf("ntech: KlijentRepo.PrebrojiPoFilteru: %w", err) + } + return broj, nil +} + // Obrisi briše klijenta po ID-u func (r *KlijentRepo) Obrisi(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, "DELETE FROM klijenti WHERE id = ?", id) diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index 8651851..694a2e8 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -3,8 +3,10 @@ package handler import ( "log/slog" "net/http" + "strconv" "strings" + "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/model" @@ -14,10 +16,16 @@ import ( // PodaciKlijenata su podaci za stranicu sa listom klijenata type PodaciKlijenata struct { model.PodaciStranice - Klijenti []model.Klijent - Pretraga string - Sacuvano bool - Obrisan bool + Klijenti []model.Klijent + Pretraga string + Sacuvano bool + Obrisan bool + StranicaBr int + UkupnoStranica int + UkupnoKlijenata int + StranicaPrev int + StranicaNext int + StranicaQueryUrl string } // PodaciFormeKlijenta su podaci za formu novog/izmenjenog klijenta @@ -28,7 +36,7 @@ type PodaciFormeKlijenta struct { Izmena bool } -// Klijenti renderuje listu svih klijenata sa opcionom pretragom +// Klijenti renderuje listu svih klijenata sa opcionom pretragom i paginacijom func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) { podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { @@ -38,21 +46,63 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) { pretraga := r.URL.Query().Get("pretraga") - klijenti, err := h.KlijentiRepo.Lista(r.Context(), pretraga) + const pageSize = 100 + stranicaBr := 1 + if p := r.URL.Query().Get("stranica"); p != "" { + if v, err := strconv.Atoi(p); err == nil && v > 0 { + stranicaBr = v + } + } + + filter := db.KlijentFilter{ + Pretraga: pretraga, + Limit: pageSize, + Offset: (stranicaBr - 1) * pageSize, + } + + klijenti, err := h.KlijentiRepo.ListaFilter(r.Context(), filter) if err != nil { http.Error(w, "Greška pri učitavanju klijenata", http.StatusInternalServerError) return } + ukupno, err := h.KlijentiRepo.PrebrojiPoFilteru(r.Context(), filter) + if err != nil { + http.Error(w, "Greška pri učitavanju klijenata", http.StatusInternalServerError) + return + } + + ukupnoStranica := (ukupno + pageSize - 1) / pageSize + + queryDelići := "" + if pretraga != "" { + queryDelići += "&pretraga=" + pretraga + } + + stranicaPrev := stranicaBr - 1 + if stranicaPrev < 1 { + stranicaPrev = 1 + } + stranicaNext := stranicaBr + 1 + if stranicaNext > ukupnoStranica { + stranicaNext = ukupnoStranica + } + ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "klijenti" ps.NaslovStranice = "Klijenti" podaci := PodaciKlijenata{ - PodaciStranice: ps, - Klijenti: klijenti, - Pretraga: pretraga, - Sacuvano: r.URL.Query().Get("sacuvano") == "1", - Obrisan: r.URL.Query().Get("obrisan") == "1", + PodaciStranice: ps, + Klijenti: klijenti, + Pretraga: pretraga, + Sacuvano: r.URL.Query().Get("sacuvano") == "1", + Obrisan: r.URL.Query().Get("obrisan") == "1", + StranicaBr: stranicaBr, + UkupnoStranica: ukupnoStranica, + UkupnoKlijenata: ukupno, + StranicaPrev: stranicaPrev, + StranicaNext: stranicaNext, + StranicaQueryUrl: queryDelići, } h.renderujTemplate(w, "klijenti", podaci) diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index 60b9ac6..8bd0e4e 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -14,13 +14,19 @@ import ( // PodaciMagacina su podaci za stranicu magacina type PodaciMagacina struct { model.PodaciStranice - Artikli []model.ArtikalSaKategorijom - Kategorije []model.Kategorija - Filter db.ArtikalFilter - KategorijaIDStr string - Sacuvano bool - Obrisan bool - Premesten bool + Artikli []model.ArtikalSaKategorijom + Kategorije []model.Kategorija + Filter db.ArtikalFilter + KategorijaIDStr string + Sacuvano bool + Obrisan bool + Premesten bool + StranicaBr int + UkupnoStranica int + UkupnoArtikala int + StranicaPrev int + StranicaNext int + StranicaQueryUrl string // čuva filtere za linkove paginacije } // Magacin renderuje listu artikala @@ -45,12 +51,30 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { } } + const pageSize = 100 + stranicaBr := 1 + if p := r.URL.Query().Get("stranica"); p != "" { + if v, err := strconv.Atoi(p); err == nil && v > 0 { + stranicaBr = v + } + } + filter.Limit = pageSize + filter.Offset = (stranicaBr - 1) * pageSize + artikli, err := h.Artikli.Lista(r.Context(), filter) if err != nil { http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError) return } + ukupno, err := h.Artikli.PrebrojiPoFilteru(r.Context(), filter) + if err != nil { + http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError) + return + } + + ukupnoStranica := (ukupno + pageSize - 1) / pageSize + kategorije, err := h.KategorijeRepo.Lista(r.Context()) if err != nil { http.Error(w, "Greška pri učitavanju kategorija", http.StatusInternalServerError) @@ -60,15 +84,43 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "magacin" ps.NaslovStranice = "Magacin" + + // izgradi query string za paginaciju (čuva filtere) + queryDelići := "" + if v := filter.Pretraga; v != "" { + queryDelići += "&pretraga=" + v + } + if katIDStr != "" { + queryDelići += "&kategorija=" + katIDStr + } + if filter.SamoKriticni { + queryDelići += "&kriticni=1" + } + + stranicaPrev := stranicaBr - 1 + if stranicaPrev < 1 { + stranicaPrev = 1 + } + stranicaNext := stranicaBr + 1 + if stranicaNext > ukupnoStranica { + stranicaNext = ukupnoStranica + } + podaci := PodaciMagacina{ - PodaciStranice: ps, - Artikli: artikli, - Kategorije: kategorije, - Filter: filter, - KategorijaIDStr: katIDStr, - Sacuvano: r.URL.Query().Get("sacuvano") == "1", - Obrisan: r.URL.Query().Get("obrisan") == "1", - Premesten: r.URL.Query().Get("premesten") == "1", + PodaciStranice: ps, + Artikli: artikli, + Kategorije: kategorije, + Filter: filter, + KategorijaIDStr: katIDStr, + Sacuvano: r.URL.Query().Get("sacuvano") == "1", + Obrisan: r.URL.Query().Get("obrisan") == "1", + Premesten: r.URL.Query().Get("premesten") == "1", + StranicaBr: stranicaBr, + UkupnoStranica: ukupnoStranica, + UkupnoArtikala: ukupno, + StranicaPrev: stranicaPrev, + StranicaNext: stranicaNext, + StranicaQueryUrl: queryDelići, } h.renderujTemplate(w, "magacin", podaci) diff --git a/web/static/css/main.css b/web/static/css/main.css index 8a7d794..c15da08 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -1109,6 +1109,29 @@ select { .tabela tbody tr:nth-child(8) { animation-delay: 0.32s; } .tabela tbody tr:nth-child(9) { animation-delay: 0.36s; } .tabela tbody tr:nth-child(10) { animation-delay: 0.40s; } +.tabela tbody tr:nth-child(11) { animation-delay: 0.44s; } +.tabela tbody tr:nth-child(12) { animation-delay: 0.48s; } +.tabela tbody tr:nth-child(13) { animation-delay: 0.52s; } +.tabela tbody tr:nth-child(14) { animation-delay: 0.56s; } +.tabela tbody tr:nth-child(15) { animation-delay: 0.60s; } +.tabela tbody tr:nth-child(16) { animation-delay: 0.64s; } +.tabela tbody tr:nth-child(17) { animation-delay: 0.68s; } +.tabela tbody tr:nth-child(18) { animation-delay: 0.72s; } +.tabela tbody tr:nth-child(19) { animation-delay: 0.76s; } +.tabela tbody tr:nth-child(20) { animation-delay: 0.80s; } + +/* content-visibility: auto – browser preskače render elemenata van viewport-a. + Ovo rešava problem sa 1000+ redova gde skrolovanje postaje prazno + dok browser ne stigne da iscrta sve. */ +.tabela tbody tr { + content-visibility: auto; + contain-intrinsic-size: 48px; +} +[class*="-kartice"] > .animiraj, +.klijenti-kartice > .animiraj { + content-visibility: auto; + contain-intrinsic-size: 120px; +} /* Stagger mobilnih lista-kartica (parnjak gornjeg za tabele). Pogađa kartice unutar bilo kog .X-kartice kontejnera; ostali tipovi kartica (detalji/forma/ @@ -1118,6 +1141,11 @@ select { [class*="-kartice"] > .animiraj:nth-child(3) { animation-delay: 0.16s; } [class*="-kartice"] > .animiraj:nth-child(4) { animation-delay: 0.22s; } [class*="-kartice"] > .animiraj:nth-child(5) { animation-delay: 0.28s; } +[class*="-kartice"] > .animiraj:nth-child(6) { animation-delay: 0.34s; } +[class*="-kartice"] > .animiraj:nth-child(7) { animation-delay: 0.40s; } +[class*="-kartice"] > .animiraj:nth-child(8) { animation-delay: 0.46s; } +[class*="-kartice"] > .animiraj:nth-child(9) { animation-delay: 0.52s; } +[class*="-kartice"] > .animiraj:nth-child(10) { animation-delay: 0.58s; } /* Stagger naslaganih .kartica.animiraj na stranicama podešavanja/profila — JEDNO mesto. Descendant selektor (razmak, ne >): nth-child se računa po neposrednom roditelju kao i diff --git a/web/templates/stranice/klijenti.html b/web/templates/stranice/klijenti.html index b673268..0056e25 100644 --- a/web/templates/stranice/klijenti.html +++ b/web/templates/stranice/klijenti.html @@ -12,19 +12,24 @@