From d06a353a5252c3c78b4c9bb0edd6b8bc9ff4851f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 13 Jun 2026 20:45:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(pdv):=20=C5=A1ifarnik=20PDV=20stopa=20?= =?UTF-8?q?=E2=80=94=20handler,=20rute=20i=20UI=20(Faza=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handleri (prikaz, dodaj, izmeni, arhiviraj/vrati) sa validacijom i flash porukama; rute pod /admin/podesavanja/pdv-stope (dozvole podesavanja.*); stranica pdv_stope registrovana u kes.go i dodata u meni Podešavanja. Šifarnik je opšti (bez RequireModul) jer ga koristi i kalkulacija. --- cmd/ntech/main.go | 22 ++-- internal/handler/kes.go | 1 + internal/handler/pdv_stopa.go | 154 ++++++++++++++++++++++++++ web/templates/komponente/sidebar.html | 11 +- web/templates/stranice/pdv_stope.html | 90 +++++++++++++++ 5 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 internal/handler/pdv_stopa.go create mode 100644 web/templates/stranice/pdv_stope.html diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index bcbdf77..2f950c8 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -8,9 +8,9 @@ import ( "fmt" "io/fs" "log/slog" + "mime" "net/http" "os" - "mime" "path/filepath" "sort" "strconv" @@ -87,12 +87,14 @@ func main() { db, err := sqlite.OtvoriDB(putanjaBaze) if err != nil { - slog.Error("Greška pri otvaranju baze", "error", err); os.Exit(1) + slog.Error("Greška pri otvaranju baze", "error", err) + os.Exit(1) } defer db.Close() if err := sqlite.PokreniMigracije(db, migrFS); err != nil { - slog.Error("Greška pri migracijama", "error", err); os.Exit(1) + slog.Error("Greška pri migracijama", "error", err) + os.Exit(1) } slog.Info("migracije uspešno izvršene") @@ -111,7 +113,8 @@ func main() { // ključ za šifrovanje TOTP tajni u mirovanju (AES-256-GCM) totpKljuc, err := ucitajTotpKljuc() if err != nil { - slog.Error("Greška pri učitavanju ključa za TOTP", "error", err); os.Exit(1) + slog.Error("Greška pri učitavanju ključa za TOTP", "error", err) + os.Exit(1) } // jednokratno šifruj eventualne stare TOTP tajne koje su ostale kao čist tekst @@ -137,7 +140,8 @@ func main() { if os.Getenv("NTECH_ENV") == "production" { kes, err := handler.KreirajKes(templFS) if err != nil { - slog.Error("Greška pri kreiranju keša šablona", "error", err); os.Exit(1) + slog.Error("Greška pri kreiranju keša šablona", "error", err) + os.Exit(1) } h.Templates = kes slog.Info("keš šablona kreiran", "broj", len(kes)) @@ -229,6 +233,10 @@ func main() { r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste) r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled) r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem) + r.Get("/admin/podesavanja/pdv-stope", h.PdvStope) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/logo", h.OtpremiLogo) r.With(doz("podesavanja.login_pozadina")).Post("/podesavanja/login-pozadina", h.OtpremiLoginPozadinu) @@ -327,7 +335,8 @@ func main() { slog.Info("NTech pokrenut", "port", port) err = http.ListenAndServe(":"+port, r) if err != nil { - slog.Error("port je zauzet ili nije dostupan", "port", port); os.Exit(1) + slog.Error("port je zauzet ili nije dostupan", "port", port) + os.Exit(1) } } @@ -419,4 +428,3 @@ func ocistiStareBackupe(folder string, max int) { _ = os.Remove(f) } } - diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 739ab33..e0a4e91 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -25,6 +25,7 @@ var saSidebar = []string{ "magacin", "magacin_forma", "nabavke", "nabavka_forma", "nabavka_detalji", "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", + "pdv_stope", "podsetnici", "podsetnik_forma", "profil_tema", "prodaja", "prodaja_detalji", "prodaja_forma", diff --git a/internal/handler/pdv_stopa.go b/internal/handler/pdv_stopa.go new file mode 100644 index 0000000..247343e --- /dev/null +++ b/internal/handler/pdv_stopa.go @@ -0,0 +1,154 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "ntech/internal/db/sqlite" + "ntech/internal/middleware" + "ntech/internal/model" + + "github.com/go-chi/chi/v5" +) + +// validneOznakeStope su dozvoljene oznake PDV stope (vrsta po zakonu) +var validneOznakeStope = map[string]bool{ + "opsta": true, + "posebna": true, + "oslobodjeno": true, +} + +// PodaciPdvStope su podaci za stranicu šifarnika PDV stopa +type PodaciPdvStope struct { + model.PodaciStranice + Stope []model.PdvStopa +} + +// PdvStope renderuje šifarnik PDV stopa (sve stope, uključujući arhivirane) +func (h *Handler) PdvStope(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { + return + } + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + if err != nil { + http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) + return + } + stope, err := h.PdvStopeRepo.Lista(r.Context(), false) + if err != nil { + http.Error(w, "Greška pri učitavanju PDV stopa", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "podesavanja-pdv-stope" + ps.NaslovStranice = "PDV stope" + h.renderujTemplate(w, "pdv_stope", PodaciPdvStope{PodaciStranice: ps, Stope: stope}) +} + +// parsePdvStopuForma čita i proverava polja forme; vraća popunjenu stopu i poruku o grešci +func parsePdvStopuForma(r *http.Request) (model.PdvStopa, string) { + naziv := strings.TrimSpace(r.FormValue("naziv")) + oznaka := strings.TrimSpace(r.FormValue("oznaka")) + stopaTekst := strings.TrimSpace(strings.Replace(r.FormValue("stopa"), ",", ".", 1)) + redosled, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("redosled"))) + + if naziv == "" { + return model.PdvStopa{}, "Naziv stope je obavezan." + } + if !validneOznakeStope[oznaka] { + return model.PdvStopa{}, "Oznaka mora biti opšta, posebna ili oslobođeno." + } + stopa, err := strconv.ParseFloat(stopaTekst, 64) + if err != nil || stopa < 0 || stopa > 100 { + return model.PdvStopa{}, "Stopa mora biti broj između 0 i 100." + } + + return model.PdvStopa{ + Naziv: naziv, + Stopa: stopa, + Oznaka: oznaka, + Aktivna: true, + Redosled: redosled, + }, "" +} + +// DodajPdvStopu prima POST i upisuje novu stopu u šifarnik +func (h *Handler) DodajPdvStopu(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + stopa, greska := parsePdvStopuForma(r) + if greska != "" { + middleware.SetFlash(w, r, h.DB, "greska", greska) + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) + return + } + if _, err := h.PdvStopeRepo.Kreiraj(r.Context(), &stopa); err != nil { + http.Error(w, "Greška pri čuvanju PDV stope", http.StatusInternalServerError) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je dodata.") + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) +} + +// IzmeniPdvStopu prima POST i menja postojeću stopu +func (h *Handler) IzmeniPdvStopu(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { + return + } + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID stope", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + stopa, greska := parsePdvStopuForma(r) + if greska != "" { + middleware.SetFlash(w, r, h.DB, "greska", greska) + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) + return + } + stopa.ID = id + if err := h.PdvStopeRepo.Izmeni(r.Context(), &stopa); err != nil { + http.Error(w, "Greška pri izmeni PDV stope", http.StatusInternalServerError) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je izmenjena.") + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) +} + +// PromeniAktivnostPdvStope arhivira ili vraća stopu u upotrebu (toggle, bez brisanja) +func (h *Handler) PromeniAktivnostPdvStope(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { + return + } + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID stope", http.StatusBadRequest) + return + } + postojeca, err := h.PdvStopeRepo.DohvatiID(r.Context(), id) + if err != nil { + http.Error(w, "PDV stopa nije pronađena", http.StatusNotFound) + return + } + if err := h.PdvStopeRepo.PostaviAktivnu(r.Context(), id, !postojeca.Aktivna); err != nil { + http.Error(w, "Greška pri promeni statusa PDV stope", http.StatusInternalServerError) + return + } + poruka := "PDV stopa je arhivirana." + if !postojeca.Aktivna { + poruka = "PDV stopa je vraćena u upotrebu." + } + middleware.SetFlash(w, r, h.DB, "uspeh", poruka) + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) +} diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index a2bfd80..b2c296f 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -147,19 +147,19 @@ {{if index .Dozvole "podesavanja.pregled"}}
-