diff --git a/.gitignore b/.gitignore index eae6c6e..0d4face 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,20 @@ -# Binarne datoteke +# Build — izlazni binarni fajlovi /ntech /ntech.exe - -# Baza podataka -*.db -*.db-shm -*.db-wal - -# Promenljive okruženja -ntech.env +ntech-* # Privremeni fajlovi *.tmp *.log -# Kate -*.kate-swp -.*.kate-swp +# Promenljive okruženja +ntech.env +.env -# VSCodium/VSCode -.vscode/ - -# IDE podešavanja -.idea/ -*.swp +# Baza podataka +*.db +*.db-shm +*.db-wal # Backup fajlovi backups/ @@ -34,9 +25,13 @@ logs/ # Upload fajlovi web/static/uploads/ -.env +# Kate editor +*.kate-swp +.*.kate-swp -# Buildovani binarni fajlovi -ntech -ntech-* +# IDE podešavanja +.vscode/ +.idea/ +*.swp +ARHITEKTURA.md diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 87437a6..1366a33 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -120,6 +120,9 @@ func main() { h := handler.Novi(db) h.Verzija = Verzija + // verzija statičkih fajlova za cache-busting — menja se pri svakom pokretanju, + // pa novi build/restart natera brauzer da povuče sveži CSS/JS (umesto starog iz keša) + h.AssetV = strconv.FormatInt(time.Now().Unix(), 36) h.PutanjaBaze = putanjaBaze // čuva odabrani FS (disk ili embed) za hot-reload u razvoju i za keš u produkciji h.TemplatesFS = templFS @@ -144,10 +147,17 @@ func main() { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") http.FileServer(http.Dir("web/static/uploads")).ServeHTTP(w, req) }))) - // ostali statični fajlovi: disk ako postoji web/static, inače embed + // ostali statični fajlovi: disk ako postoji web/static, inače embed. + // U produkciji dug immutable keš (URL nosi ?v=verzija za cache-busting pri novom buildu); + // u razvoju bez keša, da izmene CSS/JS odmah budu vidljive bez ručnog osvežavanja. + produkcija := os.Getenv("NTECH_ENV") == "production" r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + if produkcija { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } http.FileServer(http.FS(staticFS)).ServeHTTP(w, req) }))) diff --git a/internal/db/sqlite/korisnici.go b/internal/db/sqlite/korisnici.go index fe6746d..1944041 100644 --- a/internal/db/sqlite/korisnici.go +++ b/internal/db/sqlite/korisnici.go @@ -11,6 +11,23 @@ import ( type sqliteKorisniciRepo struct{ db *sql.DB } +// dodeliOpcijeKorisnika popunjava bool i opciona polja korisnika iz skeniranih +// NULL vrednosti — deljeno između skeniraiKorisnika (jedan red) i Lista (više redova) +func dodeliOpcijeKorisnika(k *model.Korisnik, aktivan, koristiLokalnuTemu int, + lokalnaTema, lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, + lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity sql.NullString, + datumKreiranja time.Time) { + k.Aktivan = aktivan == 1 + k.LokalnaTema = lokalnaTema.String + k.KoristiLokalnuTemu = koristiLokalnuTemu == 1 + k.DatumKreiranja = datumKreiranja + k.LokalnaPozadina = lokalnaPozadina.String + k.LokalnaPozadinaOpacity = lokalnaPozadinaOpacity.String + k.LokalnaPozadinaBlur = lokalnaPozadinaBlur.String + k.LokalnaPozadinaBlurPozadine = lokalnaPozadinaBlurPozadine.String + k.LokalnaPozadinaGlassOpacity = lokalnaPozadinaGlassOpacity.String +} + // skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, error) { k := &model.Korisnik{} @@ -26,15 +43,9 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er ); err != nil { return nil, err } - k.Aktivan = aktivan == 1 - k.LokalnaTema = lokalnaTema.String - k.KoristiLokalnuTemu = koristiLokalnuTemu == 1 - k.DatumKreiranja = datumKreiranja - k.LokalnaPozadina = lokalnaPozadina.String - k.LokalnaPozadinaOpacity = lokalnaPozadinaOpacity.String - k.LokalnaPozadinaBlur = lokalnaPozadinaBlur.String - k.LokalnaPozadinaBlurPozadine = lokalnaPozadinaBlurPozadine.String - k.LokalnaPozadinaGlassOpacity = lokalnaPozadinaGlassOpacity.String + dodeliOpcijeKorisnika(k, aktivan, koristiLokalnuTemu, lokalnaTema, + lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, + lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, datumKreiranja) return k, nil } @@ -109,15 +120,9 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro &lokalnaPozadinaGlassOpacity); err != nil { return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) } - k.Aktivan = aktivan == 1 - k.LokalnaTema = lokalnaTema.String - k.KoristiLokalnuTemu = koristiLokalnuTemu == 1 - k.DatumKreiranja = datumKreiranja - k.LokalnaPozadina = lokalnaPozadina.String - k.LokalnaPozadinaOpacity = lokalnaPozadinaOpacity.String - k.LokalnaPozadinaBlur = lokalnaPozadinaBlur.String - k.LokalnaPozadinaBlurPozadine = lokalnaPozadinaBlurPozadine.String - k.LokalnaPozadinaGlassOpacity = lokalnaPozadinaGlassOpacity.String + dodeliOpcijeKorisnika(&k, aktivan, koristiLokalnuTemu, lokalnaTema, + lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, + lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, datumKreiranja) lista = append(lista, k) } return lista, nil diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index 7a1215b..898b37d 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -43,9 +43,9 @@ func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.Proda SELECT pn.id, pn.klijent_id, pn.broj_naloga, pn.napomena, pn.ukupno, pn.nacin_placanja, pn.stornirano, pn.datum, - COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv + COALESCE(kp.naziv, '') AS klijent_naziv FROM prodajni_nalozi pn - LEFT JOIN klijenti k ON k.id = pn.klijent_id + LEFT JOIN klijent_prikaz kp ON kp.id = pn.klijent_id WHERE 1=1` args := []any{} diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index 9806d04..4884e40 100644 --- a/internal/db/sqlite/servis.go +++ b/internal/db/sqlite/servis.go @@ -43,9 +43,9 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj, sn.opis_kvara, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna, sn.avans, sn.napomena, sn.garancija_do, sn.datum_prijema, sn.datum_zavrsetka, - COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv + COALESCE(kp.naziv, '') AS klijent_naziv FROM servisni_nalozi sn - LEFT JOIN klijenti k ON k.id = sn.klijent_id + LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id WHERE 1=1` args := []any{} diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index ba9f61c..18eb4ba 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -127,9 +127,9 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { prodajaRedovi, err := h.DB.QueryContext(ctx, ` SELECT pn.broj_naloga, pn.ukupno, pn.datum, - COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv + COALESCE(kp.naziv, '') AS klijent_naziv FROM prodajni_nalozi pn - LEFT JOIN klijenti k ON k.id = pn.klijent_id + LEFT JOIN klijent_prikaz kp ON kp.id = pn.klijent_id ORDER BY pn.datum DESC LIMIT 5`) if err != nil { log.Printf("dashboard: poslednje prodaje: %v", err) diff --git a/internal/handler/dobavljac.go b/internal/handler/dobavljac.go index 33df8da..36ba23f 100644 --- a/internal/handler/dobavljac.go +++ b/internal/handler/dobavljac.go @@ -5,7 +5,6 @@ import ( "strings" "ntech/internal/db/sqlite" - "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -77,9 +76,7 @@ func (h *Handler) NoviDobavljac(w http.ResponseWriter, r *http.Request) { // SacuvajDobavljaca prima POST formu i upisuje novog dobavljača u bazu func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "dobavljac.dodaj") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "dobavljac.dodaj"); !ok { return } if err := r.ParseForm(); err != nil { @@ -142,9 +139,7 @@ func (h *Handler) IzmeniDobavljaca(w http.ResponseWriter, r *http.Request) { // SacuvajIzmeneDobavljaca prima POST formu i ažurira postojećeg dobavljača u bazi func (h *Handler) SacuvajIzmeneDobavljaca(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "dobavljac.izmeni") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "dobavljac.izmeni"); !ok { return } id, err := parseID(chi.URLParam(r, "id")) @@ -185,9 +180,7 @@ func (h *Handler) SacuvajIzmeneDobavljaca(w http.ResponseWriter, r *http.Request // ObrisiDobavljaca prima POST zahtev i briše dobavljača po ID-u func (h *Handler) ObrisiDobavljaca(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "dobavljac.obrisi") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "dobavljac.obrisi"); !ok { return } id, err := parseID(chi.URLParam(r, "id")) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 3fd10eb..5f5e112 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -32,6 +32,7 @@ type Handler struct { LoginIstorijsaRepo db.LoginIstorijsaRepository DozvoleRepo db.DozvoleRepository Verzija string + AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) Templates map[string]*template.Template TemplatesFS fs.FS } @@ -78,6 +79,17 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije()) } +// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju. +// U suprotnom šalje 403 sa srpskom porukom i vraća ok=false (handler tada return-uje). +func (h *Handler) zahtevajDozvolu(w http.ResponseWriter, r *http.Request, akcija string) (*model.Korisnik, bool) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, akcija) { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return nil, false + } + return k, true +} + // popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice { // podrazumevana tema je tamna; korisnik može imati svoju lokalnu temu @@ -104,6 +116,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s } } ps.CsrfToken = middleware.CsrfToken(r.Context()) + ps.AssetV = h.AssetV ps.Flash = middleware.GetFlash(r, h.DB) // logika pozadine: diff --git a/internal/handler/izvestaji.go b/internal/handler/izvestaji.go index eedbdc9..2ee8900 100644 --- a/internal/handler/izvestaji.go +++ b/internal/handler/izvestaji.go @@ -9,7 +9,6 @@ import ( "time" "ntech/internal/db/sqlite" - "ntech/internal/middleware" "ntech/internal/model" ) @@ -74,9 +73,7 @@ type TopKlijent struct { // Izvestaji renderuje stranicu sa izveštajima func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "izvestaj.pregled") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "izvestaj.pregled"); !ok { return } ctx := r.Context() @@ -165,9 +162,9 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) { // --- stari otvoreni nalozi (>14 dana bez završetka) --- stariRed, err := h.DB.QueryContext(ctx, ` SELECT sn.id, sn.broj_naloga, sn.uredjaj, sn.status, sn.datum_prijema, - COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '—') AS klijent_naziv + COALESCE(NULLIF(kp.naziv, ''), '—') AS klijent_naziv FROM servisni_nalozi sn - LEFT JOIN klijenti k ON k.id = sn.klijent_id + LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id WHERE sn.datum_zavrsetka IS NULL AND substr(sn.datum_prijema, 1, 10) <= date('now', '-14 days') ORDER BY sn.datum_prijema ASC`) @@ -215,10 +212,11 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) { // --- top 10 klijenata po ukupnoj vrednosti --- klijRed, err := h.DB.QueryContext(ctx, ` SELECT - COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, ''))) AS naziv, + kp.naziv AS naziv, COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) AS ukupno_vrednost, COALESCE(p.broj_prodaja, 0) + COALESCE(s.broj_servisa, 0) AS broj_naloga FROM klijenti k + LEFT JOIN klijent_prikaz kp ON kp.id = k.id LEFT JOIN ( SELECT klijent_id, SUM(ukupno) AS ukupno_prodaja, COUNT(*) AS broj_prodaja FROM prodajni_nalozi GROUP BY klijent_id diff --git a/internal/handler/kategorija.go b/internal/handler/kategorija.go index af80602..81630ca 100644 --- a/internal/handler/kategorija.go +++ b/internal/handler/kategorija.go @@ -5,7 +5,6 @@ import ( "strconv" "ntech/internal/db/sqlite" - "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -48,9 +47,7 @@ func (h *Handler) Kategorije(w http.ResponseWriter, r *http.Request) { // DodajKategoriju prima POST i čuva novu kategoriju func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { - kor := middleware.KorisnikIzKonteksta(r.Context()) - if kor == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), kor.Uloga, "kategorija.dodaj") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok { return } if err := r.ParseForm(); err != nil { @@ -79,9 +76,7 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { // ObrisiKategoriju briše kategoriju po ID-u func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "kategorija.obrisi") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok { return } idStr := chi.URLParam(r, "id") diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index ec2d88b..875dc4e 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -6,7 +6,6 @@ import ( "strings" "ntech/internal/db/sqlite" - "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -78,9 +77,7 @@ func (h *Handler) NoviKlijent(w http.ResponseWriter, r *http.Request) { // SacuvajKlijenta prima POST formu i upisuje novog klijenta u bazu func (h *Handler) SacuvajKlijenta(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "klijent.dodaj") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "klijent.dodaj"); !ok { return } if err := r.ParseForm(); err != nil { @@ -153,9 +150,7 @@ func (h *Handler) IzmeniKlijenta(w http.ResponseWriter, r *http.Request) { // SacuvajIzmenuKlijenta prima POST formu i ažurira postojećeg klijenta u bazi func (h *Handler) SacuvajIzmenuKlijenta(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "klijent.izmeni") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "klijent.izmeni"); !ok { return } id, err := parseID(chi.URLParam(r, "id")) @@ -206,9 +201,7 @@ func (h *Handler) SacuvajIzmenuKlijenta(w http.ResponseWriter, r *http.Request) // ObrisiKlijenta prima POST zahtev i briše klijenta po ID-u func (h *Handler) ObrisiKlijenta(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "klijent.obrisi") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "klijent.obrisi"); !ok { return } id, err := parseID(chi.URLParam(r, "id")) diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index ac82c2b..e1fcbfb 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -6,7 +6,6 @@ import ( "ntech/internal/db" "ntech/internal/db/sqlite" - "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -78,9 +77,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { // PremestiArtikal menja kategoriju artikla (premeštanje u drugu kategoriju). // Prazno polje kategorija_id znači premeštanje u "bez kategorije". func (h *Handler) PremestiArtikal(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "artikal.premesti") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "artikal.premesti"); !ok { return } id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) @@ -109,9 +106,7 @@ func (h *Handler) PremestiArtikal(w http.ResponseWriter, r *http.Request) { // ObrisiArtikal briše artikal po ID-u func (h *Handler) ObrisiArtikal(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "artikal.obrisi") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "artikal.obrisi"); !ok { return } idStr := chi.URLParam(r, "id") diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index efa36e0..be106f0 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -9,7 +9,6 @@ import ( "ntech/internal/db" "ntech/internal/db/sqlite" - "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -122,9 +121,7 @@ func (h *Handler) NovaNabavka(w http.ResponseWriter, r *http.Request) { // SacuvajNabavku prima POST formu, parsira stavke i upisuje nabavku u bazu func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "nabavka.dodaj") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "nabavka.dodaj"); !ok { return } if err := r.ParseForm(); err != nil { @@ -211,9 +208,7 @@ func (h *Handler) DetaljiNabavke(w http.ResponseWriter, r *http.Request) { // ObrisiNabavku prima POST zahtev i briše nabavku po ID-u func (h *Handler) ObrisiNabavku(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "nabavka.obrisi") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "nabavka.obrisi"); !ok { return } id, err := parseID(chi.URLParam(r, "id")) diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index e5a3b62..3b96788 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -56,9 +56,7 @@ var validnoImeBackupa = regexp.MustCompile(`^ntech_\d{8}_\d{6}\.db$`) // Podesavanja renderuje stranicu podešavanja func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.pregled") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) @@ -134,9 +132,7 @@ func vrednostIliDefault(m map[string]string, kljuc, podrazumevano string) string // VratiBackup zamenjuje trenutnu bazu sa izabranim backup fajlom func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "backup.pokreni") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "backup.pokreni"); !ok { return } if err := r.ParseForm(); err != nil { @@ -219,9 +215,7 @@ func kopiraFajl(izvor, odrediste string) error { // SacuvajPodesavanja prima POST i čuva podešavanja u bazu func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.izmeni") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { return } if err := r.ParseForm(); err != nil { @@ -285,9 +279,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { // BackupBaze kreira konzistentnu kopiju baze i šalje je kao attachment func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "backup.pregled") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "backup.pregled"); !ok { return } privremeni := fmt.Sprintf("%s/ntech_backup_%s.db", os.TempDir(), time.Now().Format("20060102_150405")) @@ -306,9 +298,7 @@ func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) { // OtpremiLogo prima multipart upload slike loga i čuva je u web/static/uploads/ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.izmeni") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { return } // ograničavamo telo zahteva na 2MB + malo za zaglavlja forme @@ -404,9 +394,7 @@ func generisiImeUploada(ext string) (string, error) { // OtpremiLoginPozadinu prima multipart upload slike i čuva je kao pozadinsku sliku login stranice func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.login_pozadina") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.login_pozadina"); !ok { return } @@ -508,9 +496,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { // UkloniLoginPozadinu briše pozadinsku sliku login stranice sa diska i iz podešavanja func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.login_pozadina") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.login_pozadina"); !ok { return } @@ -536,9 +522,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) { // SacuvajLoginPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine login stranice func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.login_pozadina") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.login_pozadina"); !ok { return } if err := r.ParseForm(); err != nil { @@ -630,9 +614,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac // PodesavanjaOpste renderuje stranicu sa opštim podešavanjima (firma i logo) func (h *Handler) PodesavanjaOpste(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.pregled") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Opšte") @@ -646,9 +628,7 @@ func (h *Handler) PodesavanjaOpste(w http.ResponseWriter, r *http.Request) { // PodesavanjaIzgled renderuje stranicu sa podešavanjima izgleda (pozadine i tema) func (h *Handler) PodesavanjaIzgled(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.pregled") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Izgled") @@ -662,9 +642,7 @@ func (h *Handler) PodesavanjaIzgled(w http.ResponseWriter, r *http.Request) { // PodesavanjaSistem renderuje stranicu sa sistemskim podešavanjima (backup) func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.pregled") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Sistem") diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 91a2a20..a96b979 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -137,9 +137,8 @@ func (h *Handler) NovaProdaja(w http.ResponseWriter, r *http.Request) { // SacuvajProdaju prima POST formu, parsira stavke i upisuje prodajni nalog u bazu func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.dodaj") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + k, ok := h.zahtevajDozvolu(w, r, "prodaja.dodaj") + if !ok { return } if err := r.ParseForm(); err != nil { @@ -307,9 +306,7 @@ func (h *Handler) StampaProdaje(w http.ResponseWriter, r *http.Request) { // ObrisiProdaju prima POST zahtev, vraća stanje na magacin i briše nalog func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.obrisi") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok { return } id, err := parseID(chi.URLParam(r, "id")) diff --git a/internal/handler/servis.go b/internal/handler/servis.go index efb888e..a8a9031 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -122,9 +122,7 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) { // SacuvajNalog prima POST formu i upisuje novi servisni nalog u bazu func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.dodaj") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "servis.dodaj"); !ok { return } if err := r.ParseForm(); err != nil { @@ -223,9 +221,7 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) { // SacuvajIzmenaNaloga prima POST formu i ažurira postojeći servisni nalog func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.izmeni") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok { return } id, err := parseID(chi.URLParam(r, "id")) @@ -286,9 +282,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) { // ObrisiNalog prima POST zahtev i briše servisni nalog po ID-u func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.obrisi") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + if _, ok := h.zahtevajDozvolu(w, r, "servis.obrisi"); !ok { return } id, err := parseID(chi.URLParam(r, "id")) @@ -370,9 +364,8 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { // DodajDeloNalogu prima POST formu i dodaje artikal kao deo servisnog naloga func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.izmeni") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + k, ok := h.zahtevajDozvolu(w, r, "servis.izmeni") + if !ok { return } @@ -421,9 +414,8 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { // ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) { - k := middleware.KorisnikIzKonteksta(r.Context()) - if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.izmeni") { - http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + k, ok := h.zahtevajDozvolu(w, r, "servis.izmeni") + if !ok { return } diff --git a/internal/model/stranica.go b/internal/model/stranica.go index 8d10e37..02a707c 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -42,6 +42,7 @@ type PodaciStranice struct { KorisnikIme string // korisničko ime prijavljenog korisnika KorisnikUloga string // uloga: "superadmin", "admin", "radnik" CsrfToken string // CSRF zaštitni token za forme + AssetV string // verzija statičkih fajlova (cache-busting za CSS/JS) Dozvole map[string]bool // mapa akcija → dozvoljeno/nije Flash *FlashPoruka // jednokratna poruka nakon redirecta // app pozadina — popunjava se iz podešavanja za sve stranice diff --git a/migrations/038_view_klijent_prikaz.sql b/migrations/038_view_klijent_prikaz.sql new file mode 100644 index 0000000..fd23712 --- /dev/null +++ b/migrations/038_view_klijent_prikaz.sql @@ -0,0 +1,8 @@ +-- View za prikazni naziv klijenta — objedinjuje logiku koja se ranije ponavljala +-- u upitima prodaje, servisa, dashboarda i izveštaja. +-- Za pravno lice vraća naziv firme; za fizičko "ime prezime"; inače prazan string. +CREATE VIEW IF NOT EXISTS klijent_prikaz AS +SELECT id, + COALESCE(NULLIF(naziv_firme, ''), + TRIM(COALESCE(ime, '') || ' ' || COALESCE(prezime, '')), '') AS naziv +FROM klijenti; diff --git a/web/static/css/main.css b/web/static/css/main.css index 636e84c..7e16284 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -602,6 +602,7 @@ input[type="number"], input[type="date"], select { height: 38px; + width: 100%; padding: 8px 12px; box-sizing: border-box; line-height: 1.5; @@ -616,10 +617,12 @@ select { textarea { height: auto; + width: 100%; min-height: 80px; padding: 8px 12px; box-sizing: border-box; line-height: 1.5; + resize: vertical; background: var(--kartica) !important; color: var(--tekst-glavni) !important; border: 0.5px solid var(--ivica) !important; @@ -666,6 +669,90 @@ select { color: var(--greska); } +/* ============================================================ + Komponente forme i tabela — zamena za ponavljane inline stilove. + Cilj: čitljiviji template, jedno mesto istine za izgled. + ============================================================ */ + +/* labela iznad polja forme */ +.polje-labela { + display: block; + font-size: 13px; + color: var(--tekst-sporedni); + margin-bottom: 6px; +} + +/* oznaka obaveznog polja (zvezdica) */ +.obavezno { + color: var(--greska); +} + +/* sitan pomoćni/sporedni tekst (objašnjenja, vrednosti u listama) */ +.pomocni-tekst { + font-size: 13px; + color: var(--tekst-sporedni); +} + +/* vertikalni raspored polja unutar forme */ +.forma-kolona { + display: flex; + flex-direction: column; + gap: 18px; +} + +/* grid raspored polja — osnovne (desktop) vrednosti; + responsivni override na uže ekrane je niže u media bloku */ +.forma-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.forma-grid-4 { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 12px; +} + +/* === tabele === */ +.tabela { + width: 100%; + border-collapse: collapse; +} +.tabela thead tr { + border-bottom: 0.5px solid var(--ivica); +} +.tabela th { + padding: 12px 16px; + text-align: left; + font-size: 12px; + font-weight: 500; + color: var(--tekst-jak); +} +.tabela td { + padding: 12px 16px; +} +.tabela .centar { + text-align: center; +} + +/* kartica koja sadrži tabelu — bez paddinga, zaobljeni uglovi sečeni */ +.kartica-tabela { + padding: 0; + overflow: hidden; +} +/* horizontalni skrol omotač oko tabele na uskim ekranima */ +.tabela-skrol { + overflow-x: auto; +} + +/* prazno stanje (nema podataka) u tabeli ili listi kartica */ +.prazno-stanje { + padding: 32px; + text-align: center; + font-size: 14px; + color: var(--tekst-sporedni); +} + /* overlay za mobilni — tamni sloj iza sidebara */ .sidebar-overlay { display: none; diff --git a/web/templates/stranice/klijent_forma.html b/web/templates/stranice/klijent_forma.html index f1217d0..fe40843 100644 --- a/web/templates/stranice/klijent_forma.html +++ b/web/templates/stranice/klijent_forma.html @@ -20,7 +20,7 @@ {{end}} {{define "sadrzaj"}} -
+
@@ -40,7 +40,7 @@
-
+
@@ -64,42 +64,42 @@
Ime i prezime
-
+
-
- + + placeholder="npr. Petrović">
- + + placeholder="13 cifara" maxlength="13">