From f4a9c1eefe4407cd5c39b78c83fb50cdb76601a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sun, 21 Jun 2026 01:00:56 +0200 Subject: [PATCH] =?UTF-8?q?Dinari:=20formatiranje=20iznosa=20sa=20separato?= =?UTF-8?q?rom=20hiljada=20(helper=20dinari/dinariCeli);=20nabavka=20po=20?= =?UTF-8?q?dobavlja=C4=8Du=20(auto-veza,=20filter=20artikala,=20izbor=20do?= =?UTF-8?q?bavlja=C4=8Da=20u=20formi=20artikla);=20UI=20doterivanja=20stav?= =?UTF-8?q?ki=20nabavke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/db/repository.go | 8 ++ internal/db/sqlite/artikal.go | 73 ++++++++++++++ internal/handler/kes.go | 47 +++++++++ internal/handler/magacin_forma.go | 95 ++++++++++++++----- internal/handler/nabavka.go | 24 ++++- web/static/js/ntech.js | 26 ++++- web/templates/stranice/dashboard.html | 4 +- web/templates/stranice/izvestaji.html | 10 +- web/templates/stranice/magacin.html | 6 +- web/templates/stranice/magacin_forma.html | 16 ++++ web/templates/stranice/nabavka_detalji.html | 18 ++-- web/templates/stranice/nabavka_forma.html | 26 +++-- web/templates/stranice/nabavke.html | 4 +- web/templates/stranice/prodaja.html | 4 +- web/templates/stranice/prodaja_detalji.html | 8 +- web/templates/stranice/prodaja_stampa.html | 6 +- web/templates/stranice/servis_detalji.html | 10 +- web/templates/stranice/servis_otpremnica.html | 10 +- web/templates/stranice/servis_predracun.html | 16 ++-- web/templates/stranice/servis_stampa.html | 6 +- web/templates/stranice/stanje_zaliha.html | 4 +- 21 files changed, 333 insertions(+), 88 deletions(-) diff --git a/internal/db/repository.go b/internal/db/repository.go index 68d8a3d..d89d552 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -31,6 +31,14 @@ type ArtikalRepository interface { SledecaSifra(ctx context.Context, kategorijaID *int64) (string, error) // KorigujKolicinu postavlja novu količinu artikla i upisuje korekciju u magacinske_promene KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error + // DobavljaciArtikla vraća ID-jeve dobavljača vezanih za artikal + DobavljaciArtikla(ctx context.Context, artikalID int64) ([]int64, error) + // PostaviDobavljaceArtikla zamenjuje skup dobavljača artikla datim ID-jevima + PostaviDobavljaceArtikla(ctx context.Context, artikalID int64, dobavljaciID []int64) error + // PoveziDobavljaca dodaje vezu artikal–dobavljač ako ne postoji (auto pri nabavci) + PoveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error + // SveDobavljaceArtikala vraća mapu artikal_id → lista dobavljac_id (za filter u nabavci) + SveDobavljaceArtikala(ctx context.Context) (map[int64][]int64, error) } // KategorijaRepository definiše operacije nad kategorijama diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index 706895e..2f690b3 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -309,6 +309,79 @@ func (r *ArtikalRepo) Vrati(ctx context.Context, id int64) error { return nil } +// DobavljaciArtikla vraća ID-jeve dobavljača vezanih za artikal +func (r *ArtikalRepo) DobavljaciArtikla(ctx context.Context, artikalID int64) ([]int64, error) { + redovi, err := r.db.QueryContext(ctx, + "SELECT dobavljac_id FROM artikal_dobavljac WHERE artikal_id = ? ORDER BY dobavljac_id", artikalID) + if err != nil { + return nil, fmt.Errorf("ntech: ArtikalRepo.DobavljaciArtikla: %w", err) + } + defer redovi.Close() + + var ids []int64 + for redovi.Next() { + var id int64 + if err := redovi.Scan(&id); err != nil { + return nil, fmt.Errorf("ntech: ArtikalRepo.DobavljaciArtikla: scan: %w", err) + } + ids = append(ids, id) + } + return ids, nil +} + +// PostaviDobavljaceArtikla zamenjuje skup dobavljača artikla datim ID-jevima (u transakciji) +func (r *ArtikalRepo) PostaviDobavljaceArtikla(ctx context.Context, artikalID int64, dobavljaciID []int64) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: begin tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, "DELETE FROM artikal_dobavljac WHERE artikal_id = ?", artikalID); err != nil { + return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: delete: %w", err) + } + for _, did := range dobavljaciID { + if _, err := tx.ExecContext(ctx, + "INSERT OR IGNORE INTO artikal_dobavljac (artikal_id, dobavljac_id) VALUES (?, ?)", artikalID, did); err != nil { + return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: insert: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: commit: %w", err) + } + return nil +} + +// PoveziDobavljaca dodaje vezu artikal–dobavljač ako ne postoji (auto pri nabavci) +func (r *ArtikalRepo) PoveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error { + _, err := r.db.ExecContext(ctx, + "INSERT OR IGNORE INTO artikal_dobavljac (artikal_id, dobavljac_id) VALUES (?, ?)", artikalID, dobavljacID) + if err != nil { + return fmt.Errorf("ntech: ArtikalRepo.PoveziDobavljaca: %w", err) + } + return nil +} + +// SveDobavljaceArtikala vraća mapu artikal_id → lista dobavljac_id +func (r *ArtikalRepo) SveDobavljaceArtikala(ctx context.Context) (map[int64][]int64, error) { + redovi, err := r.db.QueryContext(ctx, + "SELECT artikal_id, dobavljac_id FROM artikal_dobavljac ORDER BY artikal_id") + if err != nil { + return nil, fmt.Errorf("ntech: ArtikalRepo.SveDobavljaceArtikala: %w", err) + } + defer redovi.Close() + + mapa := make(map[int64][]int64) + for redovi.Next() { + var aid, did int64 + if err := redovi.Scan(&aid, &did); err != nil { + return nil, fmt.Errorf("ntech: ArtikalRepo.SveDobavljaceArtikala: scan: %w", err) + } + mapa[aid] = append(mapa[aid], did) + } + return mapa, nil +} + // KorigujKolicinu postavlja novu količinu i upisuje korekciju u magacinske_promene func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error { tx, err := r.db.BeginTx(ctx, nil) diff --git a/internal/handler/kes.go b/internal/handler/kes.go index f5fa3cb..62e6464 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -53,6 +53,15 @@ var sablonskeFunkcije = template.FuncMap{ } return fmt.Sprintf("%d", int64(math.Round(*v))) }, + // dinari formatira iznos sa separatorom hiljada (tačka) i 2 decimale (zarez): + // 1234567.5 → "1.234.567,50" + "dinari": func(v float64) string { + return formatirajDinare(v, 2) + }, + // dinariCeli formatira iznos sa separatorom hiljada, bez decimala: 1234567 → "1.234.567" + "dinariCeli": func(v float64) string { + return formatirajDinare(v, 0) + }, // statusPre vraća true ako je `a` pre `b` u redosledu statusa "statusPre": func(a, b string, statusi []string) bool { ia, ib := -1, -1 @@ -172,3 +181,41 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError) } } + +// formatirajDinare formatira broj sa tačkom kao separatorom hiljada i zarezom +// za decimale (srpski format). decimale = broj decimalnih mesta (0 ili 2). +func formatirajDinare(v float64, decimale int) string { + negativan := v < 0 + if negativan { + v = -v + } + + var ceoStr, decStr string + if decimale == 2 { + // radi u stotinkama da zaokruživanje pravilno prenese (npr. 1234567.999 → 1.234.568,00) + stotinke := int64(math.Round(v * 100)) + ceoStr = fmt.Sprintf("%d", stotinke/100) + decStr = fmt.Sprintf("%02d", stotinke%100) + } else { + ceoStr = fmt.Sprintf("%d", int64(math.Round(v))) + } + + // ubaci tačke na svake 3 cifre s desna + var sb []byte + n := len(ceoStr) + for i, c := range ceoStr { + if i > 0 && (n-i)%3 == 0 { + sb = append(sb, '.') + } + sb = append(sb, byte(c)) + } + rezultat := string(sb) + + if decimale == 2 { + rezultat += "," + decStr + } + if negativan { + rezultat = "-" + rezultat + } + return rezultat +} diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 6efd662..36d60ad 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -18,11 +18,13 @@ import ( // PodaciFormeArtikla su podaci za formu novog/izmenjenog artikla type PodaciFormeArtikla struct { model.PodaciStranice - Artikal model.Artikal - Kategorije []model.Kategorija - KategorijaIDStr string - Greska string - Izmena bool + Artikal model.Artikal + Kategorije []model.Kategorija + KategorijaIDStr string + Dobavljaci []model.Dobavljac // svi dobavljači za izbor + IzabraniDobavljaci map[int64]bool // dobavljači vezani za artikal (za checked stanje) + Greska string + Izmena bool } // NoviArtikal prikazuje formu za unos novog artikla @@ -45,12 +47,15 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) { predlogSifre = "ART-0001" } + dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "") + ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "magacin" ps.NaslovStranice = "Novi artikal" h.renderujFormuArtikla(w, PodaciFormeArtikla{ PodaciStranice: ps, Kategorije: kategorije, + Dobavljaci: dobavljaci, Artikal: model.Artikal{Sifra: predlogSifre, Tip: model.TipProizvod, JedinicaMere: "kom"}, Izmena: false, }) @@ -72,6 +77,7 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) { if greska != "" { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) kategorije, _ := h.KategorijeRepo.Lista(r.Context()) + dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "") katIDStr := "" if artikal.KategorijaID != nil { katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10) @@ -80,12 +86,14 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) { ps.Stranica = "magacin" ps.NaslovStranice = "Novi artikal" h.renderujFormuArtikla(w, PodaciFormeArtikla{ - PodaciStranice: ps, - Artikal: artikal, - Kategorije: kategorije, - KategorijaIDStr: katIDStr, - Greska: greska, - Izmena: false, + PodaciStranice: ps, + Artikal: artikal, + Kategorije: kategorije, + KategorijaIDStr: katIDStr, + Dobavljaci: dobavljaci, + IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)), + Greska: greska, + Izmena: false, }) return } @@ -108,6 +116,11 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) { } artikal.ID = id + // veži izabrane dobavljače (forma); modal nema to polje pa ostaje prazno + if e := h.Artikli.PostaviDobavljaceArtikla(r.Context(), id, citajDobavljaceForme(r)); e != nil { + slog.Error("čuvanje dobavljača artikla nije uspelo", "artikal_id", id, "error", e) + } + // fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla if r.Header.Get("X-Requested-With") == "fetch" { w.Header().Set("Content-Type", "application/json") @@ -150,15 +163,25 @@ func (h *Handler) IzmeniArtikal(w http.ResponseWriter, r *http.Request) { katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10) } + dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "") + izabrani := map[int64]bool{} + if ids, e := h.Artikli.DobavljaciArtikla(r.Context(), id); e == nil { + for _, did := range ids { + izabrani[did] = true + } + } + ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "magacin" ps.NaslovStranice = "Izmeni artikal" h.renderujFormuArtikla(w, PodaciFormeArtikla{ - PodaciStranice: ps, - Artikal: *artikal, - Kategorije: kategorije, - KategorijaIDStr: katIDStr, - Izmena: true, + PodaciStranice: ps, + Artikal: *artikal, + Kategorije: kategorije, + KategorijaIDStr: katIDStr, + Dobavljaci: dobavljaci, + IzabraniDobavljaci: izabrani, + Izmena: true, }) } @@ -185,6 +208,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { if greska != "" { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) kategorije, _ := h.KategorijeRepo.Lista(r.Context()) + dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "") artikal.ID = id katIDStr := "" if artikal.KategorijaID != nil { @@ -194,12 +218,14 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { ps.Stranica = "magacin" ps.NaslovStranice = "Izmeni artikal" h.renderujFormuArtikla(w, PodaciFormeArtikla{ - PodaciStranice: ps, - Artikal: artikal, - Kategorije: kategorije, - KategorijaIDStr: katIDStr, - Greska: greska, - Izmena: true, + PodaciStranice: ps, + Artikal: artikal, + Kategorije: kategorije, + KategorijaIDStr: katIDStr, + Dobavljaci: dobavljaci, + IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)), + Greska: greska, + Izmena: true, }) return } @@ -223,6 +249,11 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { return } + // ažuriraj dobavljače artikla prema formi + if e := h.Artikli.PostaviDobavljaceArtikla(r.Context(), id, citajDobavljaceForme(r)); e != nil { + slog.Error("čuvanje dobavljača artikla nije uspelo", "artikal_id", id, "error", e) + } + // ako se prodajna cena promenila, automatski upiši nivelacioni zapis (izvor "izmena") if razlika := artikal.ProdajnaCena - staraCena; razlika > 0.005 || razlika < -0.005 { korisnikID := &k.ID @@ -240,6 +271,26 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther) } +// mapaDobavljaca pretvara listu ID-jeva u mapu za checked stanje u formi +func mapaDobavljaca(ids []int64) map[int64]bool { + m := make(map[int64]bool, len(ids)) + for _, id := range ids { + m[id] = true + } + return m +} + +// citajDobavljaceForme čita izabrane dobavljače (checkbox dobavljaci[]) iz forme +func citajDobavljaceForme(r *http.Request) []int64 { + var ids []int64 + for _, v := range r.Form["dobavljaci"] { + if id, e := strconv.ParseInt(strings.TrimSpace(v), 10, 64); e == nil { + ids = append(ids, id) + } + } + return ids +} + // parseFormuArtikla čita polja iz forme i vraća artikal i eventualnu grešku func parseFormuArtikla(r *http.Request) (model.Artikal, string) { naziv := r.FormValue("naziv") diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index 033e076..5aa6e8f 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -46,7 +46,7 @@ type PodaciDetaljiNabavke struct { } // artikalUJSON pretvara listu artikala u template.JS vrednost bezbednu za umetanje u