From 89fa31b3921c817546b6681c77a29e88d4a87ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Wed, 1 Jul 2026 13:55:00 +0200 Subject: [PATCH] Prodaja: filteri liste (period, storno) i pretraga klijenta u novoj prodaji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lista prodaje: filter po periodu (od/do), prečica 'Ovaj mesec', filter samo stornirano - Nova prodaja: pretraga klijenta sa filterom tipa (sva/pravna/fizička lica) umesto običnog dropdown-a, sa dogruzavanjem rezultata pri skrolu - Ispravljen nedostajući CSRF token u formi nove prodaje (POST je uvek padao na 403) - Ispravljeno vizuelno poravnanje polja Način plaćanja --- internal/db/repository.go | 10 ++- internal/db/sqlite/prodaja.go | 21 +++++-- internal/handler/kpo.go | 3 +- internal/handler/pdv_kir.go | 3 +- internal/handler/prodaja.go | 68 ++++++++++++++++++--- web/static/js/ntech.js | 61 ++++++++++++++++++ web/templates/stranice/prodaja.html | 32 +++++++--- web/templates/stranice/prodaja_detalji.html | 2 +- web/templates/stranice/prodaja_forma.html | 61 +++++++++++++++--- 9 files changed, 226 insertions(+), 35 deletions(-) diff --git a/internal/db/repository.go b/internal/db/repository.go index 1d06dc0..c19ce99 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -219,9 +219,17 @@ type ServisRepository interface { SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error } +// ProdajaFilter definiše parametre za filtriranje liste prodajnih naloga +type ProdajaFilter struct { + Pretraga string + Od string // YYYY-MM-DD, prazno = bez donje granice + Do string // YYYY-MM-DD, prazno = bez gornje granice + SamoStornirano bool +} + // ProdajaRepository definiše operacije nad prodajnim nalozima type ProdajaRepository interface { - Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error) + Lista(ctx context.Context, filter ProdajaFilter) ([]model.ProdajniNalogSaDetaljem, error) DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error) diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index 71b8d86..b5ac95e 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -37,8 +37,8 @@ func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) { return fmt.Sprintf("PR-%d-%04d", godina, sledeci), nil } -// Lista vraća listu prodajnih naloga sa imenom klijenta, opcionom pretragom -func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error) { +// Lista vraća listu prodajnih naloga sa imenom klijenta, po zadatom filteru +func (r *ProdajaRepo) Lista(ctx context.Context, filter db.ProdajaFilter) ([]model.ProdajniNalogSaDetaljem, error) { upit := ` SELECT pn.id, pn.klijent_id, pn.broj_naloga, pn.napomena, pn.ukupno, @@ -50,9 +50,22 @@ func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.Proda args := []any{} - if pretraga != "" { + if filter.Pretraga != "" { upit += " AND (pn.broj_naloga LIKE ? OR kp.naziv LIKE ? OR pn.napomena LIKE ?)" - args = append(args, "%"+pretraga+"%", "%"+pretraga+"%", "%"+pretraga+"%") + args = append(args, "%"+filter.Pretraga+"%", "%"+filter.Pretraga+"%", "%"+filter.Pretraga+"%") + } + // pn.datum se čuva preko time.Time.String() (uključuje monotonic sufiks i naziv zone), + // pa ga SQLite-ova date() funkcija ne parsira ispravno — poredimo samo ISO datum na početku stringa + if filter.Od != "" { + upit += " AND substr(pn.datum, 1, 10) >= ?" + args = append(args, filter.Od) + } + if filter.Do != "" { + upit += " AND substr(pn.datum, 1, 10) <= ?" + args = append(args, filter.Do) + } + if filter.SamoStornirano { + upit += " AND pn.stornirano = 1" } upit += " ORDER BY pn.datum DESC" diff --git a/internal/handler/kpo.go b/internal/handler/kpo.go index 149a736..daf4e2c 100644 --- a/internal/handler/kpo.go +++ b/internal/handler/kpo.go @@ -7,6 +7,7 @@ import ( "strings" "time" + appdb "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" "ntech/internal/model" @@ -163,7 +164,7 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) { var kreirano, preskoceno int // prodajni nalozi - nalozi, err := h.ProdajaRepo.Lista(ctx, "") + nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{}) if err != nil { http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError) return diff --git a/internal/handler/pdv_kir.go b/internal/handler/pdv_kir.go index 059a2ab..2cd4f87 100644 --- a/internal/handler/pdv_kir.go +++ b/internal/handler/pdv_kir.go @@ -8,6 +8,7 @@ import ( "strings" "time" + appdb "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" "ntech/internal/model" @@ -222,7 +223,7 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) { ctx := r.Context() sada := time.Now() - nalozi, err := h.ProdajaRepo.Lista(ctx, "") + nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{}) if err != nil { http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError) return diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 775c201..c2bf9fe 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -25,20 +25,26 @@ import ( // PodaciProdaje su podaci za stranicu sa listom prodajnih naloga type PodaciProdaje struct { model.PodaciStranice - Nalozi []model.ProdajniNalogSaDetaljem - Sacuvano bool - Obrisan bool - Pretraga string - NemaFiskalnog map[int64]bool // ID-evi naloga bez izdatog fiskalnog računa + Nalozi []model.ProdajniNalogSaDetaljem + Sacuvano bool + Obrisan bool + Pretraga string + Od string + Do string + OvajMesecOd string + OvajMesecDo string + SamoStornirano bool + NemaFiskalnog map[int64]bool // ID-evi naloga bez izdatog fiskalnog računa } // PodaciFormeProdaje su podaci za formu unosa nove prodaje type PodaciFormeProdaje struct { model.PodaciStranice - Artikli []model.ArtikalSaKategorijom - ArtikliJSON template.JS - Klijenti []model.Klijent - Greska string + Artikli []model.ArtikalSaKategorijom + ArtikliJSON template.JS + Klijenti []model.Klijent + KlijentiJSON template.JS + Greska string } // PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama @@ -97,6 +103,29 @@ func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS { return template.JS(b) } +// klijentiUJSON pretvara listu klijenata u template.JS vrednost za pretragu na strani klijenta (naziv, tip) +func klijentiUJSON(klijenti []model.Klijent) template.JS { + type stavka struct { + ID int64 `json:"id"` + Naziv string `json:"naziv"` + Tip string `json:"tip"` + Mesto string `json:"mesto"` + Telefon string `json:"telefon"` + } + lista := make([]stavka, 0, len(klijenti)) + for _, k := range klijenti { + lista = append(lista, stavka{ + ID: k.ID, + Naziv: k.PunoIme(), + Tip: k.Tip, + Mesto: k.Mesto, + Telefon: k.Telefon, + }) + } + b, _ := json.Marshal(lista) + return template.JS(b) +} + // Prodaja renderuje listu svih prodajnih naloga func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) @@ -106,8 +135,16 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { } pretraga := strings.TrimSpace(r.URL.Query().Get("pretraga")) + od := r.URL.Query().Get("od") + do := r.URL.Query().Get("do") + samoStornirano := r.URL.Query().Get("stornirano") == "1" - nalozi, err := h.ProdajaRepo.Lista(r.Context(), pretraga) + nalozi, err := h.ProdajaRepo.Lista(r.Context(), appdb.ProdajaFilter{ + Pretraga: pretraga, + Od: od, + Do: do, + SamoStornirano: samoStornirano, + }) if err != nil { http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError) return @@ -118,6 +155,10 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { nemaFiskalnog, _ = h.FiskalRepo.ProdajeBezFiskalnog(r.Context()) } + sada := time.Now() + prviDanMeseca := time.Date(sada.Year(), sada.Month(), 1, 0, 0, 0, 0, sada.Location()) + poslednjiDanMeseca := prviDanMeseca.AddDate(0, 1, -1) + ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "prodaja" ps.NaslovStranice = "Prodaja" @@ -127,6 +168,11 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { Sacuvano: r.URL.Query().Get("sacuvano") == "1", Obrisan: r.URL.Query().Get("obrisan") == "1", Pretraga: pretraga, + Od: od, + Do: do, + OvajMesecOd: prviDanMeseca.Format("2006-01-02"), + OvajMesecDo: poslednjiDanMeseca.Format("2006-01-02"), + SamoStornirano: samoStornirano, NemaFiskalnog: nemaFiskalnog, } @@ -161,6 +207,7 @@ func (h *Handler) NovaProdaja(w http.ResponseWriter, r *http.Request) { Artikli: artikli, ArtikliJSON: artikalUJSONSaCenom(artikli), Klijenti: klijenti, + KlijentiJSON: klijentiUJSON(klijenti), }) } @@ -189,6 +236,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { Artikli: artikli, ArtikliJSON: artikalUJSONSaCenom(artikli), Klijenti: klijenti, + KlijentiJSON: klijentiUJSON(klijenti), Greska: poruka, }) } diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index e535b03..1602667 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -141,8 +141,69 @@ document.addEventListener('alpine:init', () => { pretragaArtikal: '', pdvObveznik: true, isMobile: false, + klijenti: [], + tipKlijentaFilter: 'sve', + pretragaKlijent: '', + prikaziListuKlijenata: false, + izabraniKlijent: null, + izabraniKlijentId: 0, + limitKlijenata: 30, + jeTipKlijenta(t) { + return this.tipKlijentaFilter === t + }, + klasaTipKlijenta(t) { + return this.jeTipKlijenta(t) ? 'tip-klijenta-opcija-akt' : '' + }, + postaviTipKlijenta(t) { + this.tipKlijentaFilter = t + this.limitKlijenata = 30 + }, + pretragaKlijentPromenjena() { + this.prikaziListuKlijenata = true + this.limitKlijenata = 30 + }, + // svi klijenti koji odgovaraju filteru/pretrazi (bez ograničenja) — koristi se + // da lista zna da li ima još rezultata za dogruzavanje + get svihFiltriranihKlijenata() { + const q = this.pretragaKlijent.trim().toLowerCase() + return this.klijenti.filter(k => { + if (this.tipKlijentaFilter !== 'sve' && k.tip !== this.tipKlijentaFilter) return false + if (q === '') return true + return (k.naziv || '').toLowerCase().includes(q) + }) + }, + get filtriraniKlijenti() { + return this.svihFiltriranihKlijenata.slice(0, this.limitKlijenata) + }, + get imaJosKlijenata() { + return this.limitKlijenata < this.svihFiltriranihKlijenata.length + }, + ucitajViseKlijenata(e) { + const el = e.target + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40 && this.imaJosKlijenata) { + this.limitKlijenata += 30 + } + }, + izaberiKlijenta(k) { + this.izabraniKlijent = k + this.izabraniKlijentId = k.id + this.pretragaKlijent = '' + this.prikaziListuKlijenata = false + }, + ocistiKlijenta() { + this.izabraniKlijent = null + this.izabraniKlijentId = 0 + }, + detaljKlijenta(k) { + if (!k) return '' + const d = [] + if (k.mesto) d.push(k.mesto) + if (k.telefon) d.push(k.telefon) + return d.join(' · ') + }, init() { this.artikliOpcije = window._ntechArtikli || [] + this.klijenti = window._ntechKlijenti || [] this.pdvObveznik = window._ntechPdvObveznik === true this.isMobile = window.matchMedia('(max-width: 768px)').matches window.matchMedia('(max-width: 768px)').addEventListener('change', e => { diff --git a/web/templates/stranice/prodaja.html b/web/templates/stranice/prodaja.html index 63bed84..7e94f4d 100644 --- a/web/templates/stranice/prodaja.html +++ b/web/templates/stranice/prodaja.html @@ -6,14 +6,30 @@
Prodajni nalog je uspešno obrisan.
{{end}} - -
-
- - - {{if .Pretraga}} + +
+ +
+ + +
+
+ + +
+
+ + +
+ + + {{if or .Pretraga .Od .Do .SamoStornirano}} ✕ Resetuj {{end}} + Ovaj mesec + Nova prodaja
@@ -57,7 +73,7 @@ {{else}} - {{if $.Pretraga}} Nema naloga koji odgovaraju pretrazi. {{else}} Nema prodajnih naloga. + {{if or $.Pretraga $.Od $.Do $.SamoStornirano}} Nema naloga koji odgovaraju filteru. {{else}} Nema prodajnih naloga. Dodaj prvu prodaju. {{end}} @@ -88,7 +104,7 @@
{{else}}
- {{if $.Pretraga}} Nema naloga koji odgovaraju pretrazi. {{else}} Nema prodajnih naloga. + {{if or $.Pretraga $.Od $.Do $.SamoStornirano}} Nema naloga koji odgovaraju filteru. {{else}} Nema prodajnih naloga. Dodaj prvu prodaju. {{end}}
diff --git a/web/templates/stranice/prodaja_detalji.html b/web/templates/stranice/prodaja_detalji.html index ec95130..893a71e 100644 --- a/web/templates/stranice/prodaja_detalji.html +++ b/web/templates/stranice/prodaja_detalji.html @@ -273,7 +273,7 @@

Brojač: {{.FiskalniRacun.Brojac}}

{{if .FiskalniRacun.UrlVerifikacija}}

- Provera na PURS-u → + Provera na PURS-u →

{{end}} diff --git a/web/templates/stranice/prodaja_forma.html b/web/templates/stranice/prodaja_forma.html index c9f67c7..efd3bc2 100644 --- a/web/templates/stranice/prodaja_forma.html +++ b/web/templates/stranice/prodaja_forma.html @@ -5,11 +5,21 @@ .forma-kartica:nth-child(2) { animation-delay: 0.12s; } @media (max-width: 768px) { .stavke-tabela-wrapper { display: none; } .stavke-kartice { display: flex !important; } } .polje-labela { font-size: 12px; color: var(--tekst-sporedni); display: block; margin-bottom: 4px; } + .tip-klijenta-opcija { + display: flex; align-items: center; gap: 5px; + padding: 3px 10px; border: 1px solid var(--ivica); border-radius: 20px; + cursor: pointer; font-size: 12px; color: var(--tekst-sporedni); user-select: none; + } + .tip-klijenta-opcija-akt { border-color: var(--akcentna); color: var(--tekst-glavni); background: var(--pozadina); } + .klijent-stavka:last-child { border-bottom: none; } + .klijent-stavka:hover { background: var(--pozadina); } + [x-cloak] { display: none !important; } {{end}} {{define "sadrzaj"}} @@ -22,27 +32,60 @@
+ {{if .Greska}}
{{.Greska}}
{{end}} -
+ +
Nova prodaja
- - +
+ +
+ + + +
+
+ + +
+ +
+ +
Skrolujte za još rezultata...
+
+
-
+