From 5d94ea34cf5e7c4247092ee335621121585c1f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 6 Jun 2026 17:47:52 +0200 Subject: [PATCH] Izmena u radu tema --- cmd/ntech/main.go | 20 +- internal/db/repository.go | 7 +- internal/db/sqlite/korisnici.go | 88 ++++++- internal/db/sqlite/podsetnici.go | 61 +++-- internal/handler/admin.go | 63 ++++- internal/handler/dashboard.go | 22 +- internal/handler/handler.go | 82 +++++- internal/handler/kes.go | 1 + internal/handler/nabavka.go | 18 ++ internal/handler/podesavanja.go | 207 ++++++++++----- internal/handler/podsetnici.go | 108 +++++--- internal/handler/prodaja.go | 20 ++ internal/handler/profil.go | 242 ++++++++++++++++++ internal/middleware/dozvole.go | 24 +- internal/model/korisnik.go | 10 +- internal/model/podsetnik.go | 9 + migrations/027_lokalna_tema.sql | 8 + migrations/028_tema_pre_slike.sql | 3 + migrations/029_podsetnik_korisnik.sql | 1 + migrations/030_korisnik_pozadina.sql | 3 + .../031_korisnik_pozadina_blur_pozadine.sql | 1 + web/templates/komponente/sidebar.html | 34 +++ web/templates/komponente/topbar.html | 16 +- web/templates/stranice/admin_profil.html | 14 + web/templates/stranice/dashboard.html | 2 + web/templates/stranice/magacin.html | 8 + web/templates/stranice/podesavanja.html | 8 +- .../stranice/podesavanja_izgled.html | 30 ++- web/templates/stranice/podsetnik_forma.html | 17 ++ web/templates/stranice/prodaja.html | 15 +- web/templates/stranice/profil_tema.html | 201 +++++++++++++++ web/templates/teme/podrazumevana/base.html | 18 +- 32 files changed, 1182 insertions(+), 179 deletions(-) create mode 100644 internal/handler/profil.go create mode 100644 migrations/027_lokalna_tema.sql create mode 100644 migrations/028_tema_pre_slike.sql create mode 100644 migrations/029_podsetnik_korisnik.sql create mode 100644 migrations/030_korisnik_pozadina.sql create mode 100644 migrations/031_korisnik_pozadina_blur_pozadine.sql create mode 100644 web/templates/stranice/profil_tema.html diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 8885162..dd60fbe 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -118,9 +118,17 @@ func main() { r.Use(middleware.Compress(5)) // uploads su uvek na disku — korisnički fajlovi, ne ugrađuju se - r.Handle("/static/uploads/*", http.StripPrefix("/static/uploads/", http.FileServer(http.Dir("web/static/uploads")))) + r.Handle("/static/uploads/*", http.StripPrefix("/static/uploads/", + http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + 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 - r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + 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") + http.FileServer(http.FS(staticFS)).ServeHTTP(w, req) + }))) // javne rute (bez autentifikacije) r.Get("/prijava", h.PrikazPrijave) @@ -154,6 +162,8 @@ func main() { r.Get("/podesavanja/backup", h.BackupBaze) r.Post("/podesavanja/backup/vrati", h.VratiBackup) r.Get("/tema/{tema}", h.PromeniTemu) + r.Post("/podesavanja/tema", h.PromeniGlobalnuTemu) + r.Post("/podesavanja/tema/globalno", h.PrimeniGlobalnuTemu) r.Get("/magacin", h.Magacin) r.Get("/magacin/novi", h.NoviArtikal) r.Post("/magacin/novi", h.SacuvajArtikal) @@ -192,6 +202,7 @@ func main() { r.Get("/prodaja/nova", h.NovaProdaja) r.Post("/prodaja/nova", h.SacuvajProdaju) r.Post("/prodaja/obrisi/{id}", h.ObrisiProdaju) + r.Post("/prodaja/storno/{id}", h.StornoProdaje) r.Get("/prodaja/{id}/stampa", h.StampaProdaje) r.Get("/prodaja/{id}", h.DetaljiProdaje) @@ -229,6 +240,11 @@ func main() { r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni) r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija) r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija) + r.Post("/profil/tema", h.AdminSacuvajLokalnuTemu) + r.Get("/profil/tema", h.ProfilTema) + r.Post("/profil/pozadina", h.ProfilOtpremiPozadinu) + r.Post("/profil/pozadina/ukloni", h.ProfilUkloniPozadinu) + r.Post("/profil/pozadina/stilovi", h.ProfilSacuvajPozadinuStilove) }) log.Printf("NTech pokrenut na portu %s", port) diff --git a/internal/db/repository.go b/internal/db/repository.go index 39ac308..e7bb78c 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -86,6 +86,8 @@ type KorisniciRepository interface { AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error PromeniLozinku(ctx context.Context, id int64, hash string) error SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error + SacuvajLokalnuTemu(ctx context.Context, id int64, lokalnaTema string, koristi bool) error + SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine string) error PostojiIjedan(ctx context.Context) (bool, error) Obrisi(ctx context.Context, id int64) error } @@ -101,7 +103,8 @@ type SesijeRepository interface { // PodsetnikFilter definiše parametre za filtriranje liste podsetnika type PodsetnikFilter struct { - SamoAktivni bool // true = samo nezavršeni; false = svi + SamoAktivni bool // true = samo nezavršeni; false = svi + KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika } // PokusajiPrijaveRepository definiše operacije nad evidencijom pokušaja prijave @@ -134,5 +137,5 @@ type PodsetnikRepository interface { Izmeni(ctx context.Context, p *model.Podsetnik) error OznaciZavrsenim(ctx context.Context, id int64, zavrseno bool) error Obrisi(ctx context.Context, id int64) error - BrojAktivnih(ctx context.Context) (int, error) + BrojAktivnih(ctx context.Context, filter PodsetnikFilter) (int, error) } diff --git a/internal/db/sqlite/korisnici.go b/internal/db/sqlite/korisnici.go index 861fe4a..b691a4c 100644 --- a/internal/db/sqlite/korisnici.go +++ b/internal/db/sqlite/korisnici.go @@ -29,41 +29,69 @@ func (r *sqliteKorisniciRepo) Kreiraj(ctx context.Context, korisnickoIme, lozink func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme string) (*model.Korisnik, error) { k := &model.Korisnik{} - var aktivan int - var totpTajna sql.NullString + var aktivan, koristiLokalnuTemu int + var totpTajna, lokalnaTema sql.NullString + var lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, lokalnaPozadinaBlurPozadine sql.NullString var datumKreiranja time.Time err := r.db.QueryRowContext(ctx, - `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja + `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), + COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja, + COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'), + COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0') FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme). - Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &totpTajna, &datumKreiranja) + Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &totpTajna, + &lokalnaTema, &koristiLokalnuTemu, &datumKreiranja, + &lokalnaPozadina, &lokalnaPozadinaOpacity, &lokalnaPozadinaBlur, &lokalnaPozadinaBlurPozadine) if err != nil { return nil, fmt.Errorf("ntech: korisnici.DohvatiPoImenu: %w", err) } k.Aktivan = aktivan == 1 k.TotpTajna = totpTajna.String + 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 return k, nil } func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model.Korisnik, error) { k := &model.Korisnik{} - var aktivan int + var aktivan, koristiLokalnuTemu int + var lokalnaTema sql.NullString + var lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, lokalnaPozadinaBlurPozadine sql.NullString var datumKreiranja time.Time err := r.db.QueryRowContext(ctx, - `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja + `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), + COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja, + COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'), + COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0') FROM korisnici WHERE id = ?`, id). - Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, &datumKreiranja) + Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, + &lokalnaTema, &koristiLokalnuTemu, &datumKreiranja, + &lokalnaPozadina, &lokalnaPozadinaOpacity, &lokalnaPozadinaBlur, &lokalnaPozadinaBlurPozadine) if err != nil { return nil, fmt.Errorf("ntech: korisnici.DohvatiPoID: %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 return k, nil } func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja + `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), + COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja, + COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'), + COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0') FROM korisnici ORDER BY datum_kreiranja ASC`) if err != nil { return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) @@ -72,18 +100,58 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro var lista []model.Korisnik for rows.Next() { var k model.Korisnik - var aktivan int + var aktivan, koristiLokalnuTemu int + var lokalnaTema sql.NullString + var lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, lokalnaPozadinaBlurPozadine sql.NullString var datumKreiranja time.Time - if err := rows.Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, &datumKreiranja); err != nil { + if err := rows.Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, + &lokalnaTema, &koristiLokalnuTemu, &datumKreiranja, + &lokalnaPozadina, &lokalnaPozadinaOpacity, &lokalnaPozadinaBlur, &lokalnaPozadinaBlurPozadine); 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 lista = append(lista, k) } return lista, nil } +func (r *sqliteKorisniciRepo) SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE korisnici SET lokalna_pozadina = ?, lokalna_pozadina_opacity = ?, lokalna_pozadina_blur = ?, lokalna_pozadina_blur_pozadine = ? WHERE id = ?`, + pozadina, opacity, blur, blurPozadine, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.SacuvajLokalnuPozadinu: %w", err) + } + return nil +} + +func (r *sqliteKorisniciRepo) SacuvajLokalnuTemu(ctx context.Context, id int64, lokalnaTema string, koristi bool) error { + koristiInt := 0 + if koristi { + koristiInt = 1 + } + var tema interface{} + if lokalnaTema == "" { + tema = nil + } else { + tema = lokalnaTema + } + _, err := r.db.ExecContext(ctx, + `UPDATE korisnici SET lokalna_tema = ?, koristi_lokalnu_temu = ? WHERE id = ?`, + tema, koristiInt, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.SacuvajLokalnuTemu: %w", err) + } + return nil +} + func (r *sqliteKorisniciRepo) AzurirajUlogu(ctx context.Context, id int64, uloga string) error { _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id) if err != nil { diff --git a/internal/db/sqlite/podsetnici.go b/internal/db/sqlite/podsetnici.go index a66e5fb..dd80c92 100644 --- a/internal/db/sqlite/podsetnici.go +++ b/internal/db/sqlite/podsetnici.go @@ -22,17 +22,24 @@ func NoviPodsetnikRepo(db *sql.DB) *PodsetnikRepo { // Lista vraća listu podsetnika sa opcionim filterom func (r *PodsetnikRepo) Lista(ctx context.Context, filter db.PodsetnikFilter) ([]model.Podsetnik, error) { upit := ` - SELECT id, naslov, napomena, datum_podsecanja, zavrseno, tip, datum_unosa + SELECT id, naslov, napomena, datum_podsecanja, zavrseno, tip, datum_unosa, korisnik_id FROM podsetnici WHERE 1=1` + var args []any + if filter.SamoAktivni { upit += " AND zavrseno = 0" } + if filter.KorisnikID != nil { + upit += " AND korisnik_id = ?" + args = append(args, *filter.KorisnikID) + } + upit += " ORDER BY datum_podsecanja ASC" - redovi, err := r.db.QueryContext(ctx, upit) + redovi, err := r.db.QueryContext(ctx, upit, args...) if err != nil { return nil, fmt.Errorf("ntech: PodsetnikRepo.Lista: %w", err) } @@ -42,15 +49,19 @@ func (r *PodsetnikRepo) Lista(ctx context.Context, filter db.PodsetnikFilter) ([ for redovi.Next() { var p model.Podsetnik var napomena sql.NullString + var korisnikID sql.NullInt64 var zavrseno int err := redovi.Scan( - &p.ID, &p.Naslov, &napomena, &p.DatumPodsecanja, &zavrseno, &p.Tip, &p.DatumUnosa, + &p.ID, &p.Naslov, &napomena, &p.DatumPodsecanja, &zavrseno, &p.Tip, &p.DatumUnosa, &korisnikID, ) if err != nil { return nil, fmt.Errorf("ntech: PodsetnikRepo.Lista: scan: %w", err) } p.Napomena = napomena.String p.Zavrseno = zavrseno == 1 + if korisnikID.Valid { + p.KorisnikID = &korisnikID.Int64 + } rezultat = append(rezultat, p) } @@ -61,12 +72,13 @@ func (r *PodsetnikRepo) Lista(ctx context.Context, filter db.PodsetnikFilter) ([ func (r *PodsetnikRepo) DohvatiID(ctx context.Context, id int64) (*model.Podsetnik, error) { var p model.Podsetnik var napomena sql.NullString + var korisnikID sql.NullInt64 var zavrseno int err := r.db.QueryRowContext(ctx, ` - SELECT id, naslov, napomena, datum_podsecanja, zavrseno, tip, datum_unosa + SELECT id, naslov, napomena, datum_podsecanja, zavrseno, tip, datum_unosa, korisnik_id FROM podsetnici WHERE id = ?`, id).Scan( - &p.ID, &p.Naslov, &napomena, &p.DatumPodsecanja, &zavrseno, &p.Tip, &p.DatumUnosa, + &p.ID, &p.Naslov, &napomena, &p.DatumPodsecanja, &zavrseno, &p.Tip, &p.DatumUnosa, &korisnikID, ) if err != nil { return nil, fmt.Errorf("ntech: PodsetnikRepo.DohvatiID: %w", err) @@ -74,16 +86,24 @@ func (r *PodsetnikRepo) DohvatiID(ctx context.Context, id int64) (*model.Podsetn p.Napomena = napomena.String p.Zavrseno = zavrseno == 1 + if korisnikID.Valid { + p.KorisnikID = &korisnikID.Int64 + } return &p, nil } // Kreiraj dodaje novi podsetnik u bazu func (r *PodsetnikRepo) Kreiraj(ctx context.Context, p *model.Podsetnik) (int64, error) { + var korisnikID interface{} + if p.KorisnikID != nil { + korisnikID = *p.KorisnikID + } + rezultat, err := r.db.ExecContext(ctx, ` - INSERT INTO podsetnici (naslov, napomena, datum_podsecanja, tip) - VALUES (?, ?, ?, ?)`, - p.Naslov, nullString(p.Napomena), p.DatumPodsecanja, p.Tip, + INSERT INTO podsetnici (naslov, napomena, datum_podsecanja, tip, korisnik_id) + VALUES (?, ?, ?, ?, ?)`, + p.Naslov, nullString(p.Napomena), p.DatumPodsecanja, p.Tip, korisnikID, ) if err != nil { return 0, fmt.Errorf("ntech: PodsetnikRepo.Kreiraj: %w", err) @@ -99,11 +119,16 @@ func (r *PodsetnikRepo) Kreiraj(ctx context.Context, p *model.Podsetnik) (int64, // Izmeni ažurira postojeći podsetnik func (r *PodsetnikRepo) Izmeni(ctx context.Context, p *model.Podsetnik) error { + var korisnikID interface{} + if p.KorisnikID != nil { + korisnikID = *p.KorisnikID + } + _, err := r.db.ExecContext(ctx, ` UPDATE podsetnici SET - naslov = ?, napomena = ?, datum_podsecanja = ?, tip = ? + naslov = ?, napomena = ?, datum_podsecanja = ?, tip = ?, korisnik_id = ? WHERE id = ?`, - p.Naslov, nullString(p.Napomena), p.DatumPodsecanja, p.Tip, p.ID, + p.Naslov, nullString(p.Napomena), p.DatumPodsecanja, p.Tip, korisnikID, p.ID, ) if err != nil { return fmt.Errorf("ntech: PodsetnikRepo.Izmeni: %w", err) @@ -138,12 +163,18 @@ func (r *PodsetnikRepo) Obrisi(ctx context.Context, id int64) error { return nil } -// BrojAktivnih vraća broj nezavršenih podsetnika -func (r *PodsetnikRepo) BrojAktivnih(ctx context.Context) (int, error) { +// BrojAktivnih vraća broj nezavršenih podsetnika, opcionalno filtrirano po korisniku +func (r *PodsetnikRepo) BrojAktivnih(ctx context.Context, filter db.PodsetnikFilter) (int, error) { + upit := "SELECT COUNT(*) FROM podsetnici WHERE zavrseno = 0" + var args []any + + if filter.KorisnikID != nil { + upit += " AND korisnik_id = ?" + args = append(args, *filter.KorisnikID) + } + var broj int - err := r.db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM podsetnici WHERE zavrseno = 0", - ).Scan(&broj) + err := r.db.QueryRowContext(ctx, upit, args...).Scan(&broj) if err != nil { return 0, fmt.Errorf("ntech: PodsetnikRepo.BrojAktivnih: %w", err) } diff --git a/internal/handler/admin.go b/internal/handler/admin.go index dc69163..8fadd01 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -30,7 +30,22 @@ type podaciAdminProfil struct { TotpURI string TotpTajna string TotpQR template.URL - TotpAktivan bool + TotpAktivan bool + LokalnaTema string + KoristiLokalnuTemu bool + GlobalnaTema string +} + +type podaciProfilTema struct { + model.PodaciStranice + LokalnaTema string + KoristiLokalnuTemu bool + KoristiGlobalnuTemu bool // !KoristiLokalnuTemu — za Alpine.js svič "Koristi globalnu temu" + GlobalnaTema string + LokalnaPozadina string + LokalnaPozadinaOpacity string + LokalnaPozadinaBlur string + LokalnaPozadinaBlurPozadine string } // AdminKorisnici prikazuje listu korisnika @@ -285,11 +300,53 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) { ps.NaslovStranice = "Moj profil" h.renderujTemplate(w, "admin_profil", podaciAdminProfil{ - PodaciStranice: ps, - TotpAktivan: svezi.TotpTajna != "", + PodaciStranice: ps, + TotpAktivan: svezi.TotpTajna != "", + LokalnaTema: svezi.LokalnaTema, + KoristiLokalnuTemu: svezi.KoristiLokalnuTemu, + GlobalnaTema: podesavanja["globalna_tema"], }) } +// AdminSacuvajLokalnuTemu čuva korisnikovu lokalnu temu +func (h *Handler) AdminSacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.") + http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) + return + } + + koristi := r.FormValue("koristi_lokalnu_temu") == "1" + lokalnaTema := r.FormValue("lokalna_tema") + if lokalnaTema != "tamna" && lokalnaTema != "svetla" { + lokalnaTema = "tamna" + } + + if err := h.KorisniciRepo.SacuvajLokalnuTemu(r.Context(), k.ID, lokalnaTema, koristi); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.") + http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) + return + } + + middleware.SetFlash(w, r, h.DB, "uspeh", "Tema je sačuvana.") + // vrati korisnika na stranicu odakle je došao (Referer), ili na profil kao fallback + if ref := r.Referer(); ref != "" { + http.Redirect(w, r, ref, http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) +} + // AdminPromeniLozinku menja lozinku prijavljenog korisnika func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) { k := middleware.KorisnikIzKonteksta(r.Context()) diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index d0dd464..9fec234 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -5,7 +5,9 @@ import ( "net/http" "time" + appdb "ntech/internal/db" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" ) @@ -47,11 +49,15 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { log.Printf("dashboard: aktivni servisi: %v", err) } - if err := h.DB.QueryRowContext(ctx, ` - SELECT COALESCE(SUM(ukupno), 0) FROM prodajni_nalozi - WHERE substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')`, - ).Scan(&prihodOvogMeseca); err != nil { - log.Printf("dashboard: prihod ovog meseca: %v", err) + // prihod se dohvata samo ako korisnik ima dozvolu dashboard.prihod + korisnikDash := middleware.KorisnikIzKonteksta(ctx) + if h.DozvoleRepo.ImaDozvolu(ctx, korisnikDash.Uloga, "dashboard.prihod") { + if err := h.DB.QueryRowContext(ctx, ` + SELECT COALESCE(SUM(ukupno), 0) FROM prodajni_nalozi + WHERE substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')`, + ).Scan(&prihodOvogMeseca); err != nil { + log.Printf("dashboard: prihod ovog meseca: %v", err) + } } if err := h.DB.QueryRowContext(ctx, @@ -60,7 +66,11 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { log.Printf("dashboard: kriticna zaliha: %v", err) } - if n, err := h.PodsetniciFRepo.BrojAktivnih(ctx); err != nil { + korisnikFilter := appdb.PodsetnikFilter{} + if korisnikDash.Uloga == "radnik" { + korisnikFilter.KorisnikID = &korisnikDash.ID + } + if n, err := h.PodsetniciFRepo.BrojAktivnih(ctx, korisnikFilter); err != nil { log.Printf("dashboard: aktivni podsetnici: %v", err) } else { aktivniPodsetnici = n diff --git a/internal/handler/handler.go b/internal/handler/handler.go index bf12c00..6b26d2c 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -74,35 +74,91 @@ func (h *Handler) reinicijalzijRepozitorijume(novaDB *sql.DB) { // popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice { + // redosled prioriteta teme: pozadinska slika → lokalna → globalna → fallback + globalnaTema := podesavanja["globalna_tema"] + if globalnaTema == "" { + globalnaTema = podesavanja["tema"] + } + if globalnaTema == "" { + globalnaTema = "tamna" + } + tema := globalnaTema + ps := model.PodaciStranice{ - Tema: podesavanja["tema"], + Tema: tema, NazivFirme: podesavanja["naziv_firme"], Podnazlov: podesavanja["podnazlov"], LogoTip: podesavanja["logo_tip"], LogoPutanja: podesavanja["logo_putanja"], Korisnik: "Admin", } + var korisnik *model.Korisnik if k := middleware.KorisnikIzKonteksta(r.Context()); k != nil { + korisnik = k ps.Korisnik = k.KorisnickoIme ps.KorisnikIme = k.KorisnickoIme ps.KorisnikUloga = k.Uloga ps.Dozvole = h.DozvoleRepo.SveDozvole(r.Context(), k.Uloga) + // lokalna tema korisnika + if k.KoristiLokalnuTemu && k.LokalnaTema != "" { + ps.Tema = k.LokalnaTema + } } ps.CsrfToken = middleware.CsrfToken(r.Context()) ps.Flash = middleware.GetFlash(r, h.DB) - ps.AppPozadina = podesavanja["app_pozadina"] - ps.AppPozadinaOpacity = podesavanja["app_pozadina_opacity"] - if ps.AppPozadinaOpacity == "" { - ps.AppPozadinaOpacity = "50" - } - ps.AppPozadinaBlur = podesavanja["app_pozadina_blur"] - if ps.AppPozadinaBlur == "" { - ps.AppPozadinaBlur = "12" - } - ps.AppPozadinaBlurPozadine = podesavanja["app_pozadina_blur_pozadine"] - if ps.AppPozadinaBlurPozadine == "" { - ps.AppPozadinaBlurPozadine = "0" + // logika pozadine: + // - lična pozadina (samo kada je lokalni režim aktivan) → zamenjuje globalnu + // - globalna pozadina → prikazuje se svima koji nemaju ličnu + // KoristiLokalnuTemu utiče na izbor tamne/svetle teme, ne na vidljivost pozadine + if korisnik != nil && korisnik.KoristiLokalnuTemu && korisnik.LokalnaPozadina != "" { + ps.AppPozadina = korisnik.LokalnaPozadina + ps.Tema = "tamna" + ps.AppPozadinaOpacity = korisnik.LokalnaPozadinaOpacity + if ps.AppPozadinaOpacity == "" { + ps.AppPozadinaOpacity = "50" + } + ps.AppPozadinaBlur = korisnik.LokalnaPozadinaBlur + if ps.AppPozadinaBlur == "" { + ps.AppPozadinaBlur = "12" + } + ps.AppPozadinaBlurPozadine = korisnik.LokalnaPozadinaBlurPozadine + if ps.AppPozadinaBlurPozadine == "" { + ps.AppPozadinaBlurPozadine = "0" + } + } else { + ps.AppPozadina = podesavanja["app_pozadina"] + if ps.AppPozadina != "" { + // globalna pozadina forsira tamnu temu, osim ako korisnik ima aktivnu lokalnu temu + if korisnik == nil || !korisnik.KoristiLokalnuTemu { + ps.Tema = "tamna" + } + ps.AppPozadinaOpacity = podesavanja["app_pozadina_opacity"] + if ps.AppPozadinaOpacity == "" { + ps.AppPozadinaOpacity = "50" + } + ps.AppPozadinaBlur = podesavanja["app_pozadina_blur"] + if ps.AppPozadinaBlur == "" { + ps.AppPozadinaBlur = "12" + } + ps.AppPozadinaBlurPozadine = podesavanja["app_pozadina_blur_pozadine"] + if ps.AppPozadinaBlurPozadine == "" { + ps.AppPozadinaBlurPozadine = "0" + } + } else { + ps.AppPozadinaOpacity = podesavanja["app_pozadina_opacity"] + if ps.AppPozadinaOpacity == "" { + ps.AppPozadinaOpacity = "50" + } + ps.AppPozadinaBlur = podesavanja["app_pozadina_blur"] + if ps.AppPozadinaBlur == "" { + ps.AppPozadinaBlur = "12" + } + ps.AppPozadinaBlurPozadine = podesavanja["app_pozadina_blur_pozadine"] + if ps.AppPozadinaBlurPozadine == "" { + ps.AppPozadinaBlurPozadine = "0" + } + } } return ps diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 15cd121..3f36a22 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -26,6 +26,7 @@ var saSidebar = []string{ "nabavke", "nabavka_forma", "nabavka_detalji", "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podsetnici", "podsetnik_forma", + "profil_tema", "prodaja", "prodaja_detalji", "prodaja_forma", "servis", "servis_forma", "servis_detalji", } diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index efa36e0..6b58e57 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -57,6 +57,12 @@ func artikalUJSON(artikli []model.ArtikalSaKategorijom) template.JS { // Nabavke renderuje listu svih nabavki func (h *Handler) Nabavke(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "nabavka.pregled") { + http.Error(w, "Nemate dozvolu za pregled nabavki.", http.StatusForbidden) + return + } + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) @@ -84,6 +90,12 @@ func (h *Handler) Nabavke(w http.ResponseWriter, r *http.Request) { // NovaNabavka prikazuje formu za unos nove nabavke func (h *Handler) NovaNabavka(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "nabavka.pregled") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) @@ -163,6 +175,12 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) { // DetaljiNabavke prikazuje pregled jedne nabavke sa svim stavkama func (h *Handler) DetaljiNabavke(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "nabavka.pregled") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } + id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID nabavke", http.StatusBadRequest) diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 446ecde..53ea3a2 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -32,7 +32,7 @@ type PodaciPodesavanja struct { PIB string LogoTip string LogoPutanja string - Tema string + GlobalnaTema string // stvarna vrednost iz podešavanja (ne senči PodaciStranice.Tema) Sacuvano bool Verzija string LogoGreska string @@ -83,7 +83,15 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) { PIB: podesavanja["pib"], LogoTip: podesavanja["logo_tip"], LogoPutanja: podesavanja["logo_putanja"], - Tema: podesavanja["tema"], + GlobalnaTema: func() string { + if v := podesavanja["globalna_tema"]; v != "" { + return v + } + if v := podesavanja["tema"]; v != "" { + return v + } + return "tamna" + }(), Sacuvano: r.URL.Query().Get("sacuvano") == "1", BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", Verzija: h.Verzija, @@ -252,13 +260,14 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { } polja := map[string]string{ - "naziv_firme": r.FormValue("naziv_firme"), - "podnazlov": r.FormValue("podnazlov"), - "adresa": r.FormValue("adresa"), - "telefon": r.FormValue("telefon"), - "pib": r.FormValue("pib"), - "logo_tip": r.FormValue("logo_tip"), - "tema": r.FormValue("tema"), + "naziv_firme": r.FormValue("naziv_firme"), + "podnazlov": r.FormValue("podnazlov"), + "adresa": r.FormValue("adresa"), + "telefon": r.FormValue("telefon"), + "pib": r.FormValue("pib"), + "logo_tip": r.FormValue("logo_tip"), + "tema": r.FormValue("tema"), + "globalna_tema": r.FormValue("globalna_tema"), } for kljuc, vrednost := range polja { @@ -409,21 +418,21 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 5<<20+4096) if err := r.ParseMultipartForm(5 << 20); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } fajl, zaglavlje, err := r.FormFile("login_pozadina") if err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Nije odabran fajl.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } defer fajl.Close() if zaglavlje.Size > 5<<20 { middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -437,7 +446,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { ocekivaniMime, ok := dozvoljenoExt[ext] if !ok { middleware.SetFlash(w, r, h.DB, "greska", "Dozvoljeni formati su JPG, PNG i WebP.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -447,12 +456,12 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { stvarniMime := http.DetectContentType(buf[:n]) if !strings.HasPrefix(stvarniMime, ocekivaniMime) { middleware.SetFlash(w, r, h.DB, "greska", "Sadržaj fajla ne odgovara odabranoj ekstenziji.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } if _, err := fajl.Seek(0, io.SeekStart); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Greška pri obradi fajla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -469,7 +478,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("upload login pozadine: greška pri generisanju imena: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -478,7 +487,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("upload login pozadine: ne mogu kreirati fajl: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } defer dst.Close() @@ -486,7 +495,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { if _, err := io.Copy(dst, fajl); err != nil { log.Printf("upload login pozadine: greška pri kopiranju: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -494,12 +503,12 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", putanja); err != nil { log.Printf("upload login pozadine: greška pri čuvanju putanje: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) } // UkloniLoginPozadinu briše pozadinsku sliku login stranice sa diska i iz podešavanja @@ -522,12 +531,12 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) { if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", ""); err != nil { log.Printf("ukloni login pozadinu: greška pri čuvanju: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) } // OtpremiAppPozadinu prima multipart upload slike i čuva je kao pozadinsku sliku aplikacije @@ -541,21 +550,21 @@ func (h *Handler) OtpremiAppPozadinu(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 5<<20+4096) if err := r.ParseMultipartForm(5 << 20); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } fajl, zaglavlje, err := r.FormFile("app_pozadina") if err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Nije odabran fajl.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } defer fajl.Close() if zaglavlje.Size > 5<<20 { middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -569,7 +578,7 @@ func (h *Handler) OtpremiAppPozadinu(w http.ResponseWriter, r *http.Request) { ocekivaniMime, ok := dozvoljenoExt[ext] if !ok { middleware.SetFlash(w, r, h.DB, "greska", "Dozvoljeni formati su JPG, PNG i WebP.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -578,12 +587,12 @@ func (h *Handler) OtpremiAppPozadinu(w http.ResponseWriter, r *http.Request) { stvarniMime := http.DetectContentType(buf[:n]) if !strings.HasPrefix(stvarniMime, ocekivaniMime) { middleware.SetFlash(w, r, h.DB, "greska", "Sadržaj fajla ne odgovara odabranoj ekstenziji.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } if _, err := fajl.Seek(0, io.SeekStart); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Greška pri obradi fajla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -599,7 +608,7 @@ func (h *Handler) OtpremiAppPozadinu(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("upload app pozadine: greška pri generisanju imena: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -608,7 +617,7 @@ func (h *Handler) OtpremiAppPozadinu(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("upload app pozadine: ne mogu kreirati fajl: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } defer dst.Close() @@ -616,7 +625,7 @@ func (h *Handler) OtpremiAppPozadinu(w http.ResponseWriter, r *http.Request) { if _, err := io.Copy(dst, fajl); err != nil { log.Printf("upload app pozadine: greška pri kopiranju: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -624,12 +633,23 @@ func (h *Handler) OtpremiAppPozadinu(w http.ResponseWriter, r *http.Request) { if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "app_pozadina", putanja); err != nil { log.Printf("upload app pozadine: greška pri čuvanju putanje: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } + // zapamti trenutnu globalnu temu pre nego što je forsiramo na tamnu + trenutnaTema := staraPodesavanja["globalna_tema"] + if trenutnaTema == "" { + trenutnaTema = staraPodesavanja["tema"] + } + if trenutnaTema == "" { + trenutnaTema = "tamna" + } + _ = ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema_pre_slike", trenutnaTema) + _ = ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "globalna_tema", "tamna") + middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika aplikacije je uspešno otpremljena.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) } // UkloniAppPozadinu briše pozadinsku sliku aplikacije sa diska i iz podešavanja @@ -652,12 +672,22 @@ func (h *Handler) UkloniAppPozadinu(w http.ResponseWriter, r *http.Request) { if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "app_pozadina", ""); err != nil { log.Printf("ukloni app pozadinu: greška pri čuvanju: %v", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } + // vrati temu koja je bila aktivna pre dodavanja slike + if err == nil { + temaPreSlike := podesavanja["tema_pre_slike"] + if temaPreSlike == "" { + temaPreSlike = "tamna" + } + _ = ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "globalna_tema", temaPreSlike) + _ = ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema_pre_slike", "") + } + middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika aplikacije je uklonjena.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) } // SacuvajAppPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine aplikacije @@ -669,7 +699,7 @@ func (h *Handler) SacuvajAppPozadinaStilove(w http.ResponseWriter, r *http.Reque } if err := r.ParseForm(); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čitanju forme.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -680,19 +710,19 @@ func (h *Handler) SacuvajAppPozadinaStilove(w http.ResponseWriter, r *http.Reque blurVal, err := strconv.Atoi(blurStr) if err != nil || blurVal < 0 || blurVal > 20 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost zamućenja stakla.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } blurPozadineVal, err := strconv.Atoi(blurPozadineStr) if err != nil || blurPozadineVal < 0 || blurPozadineVal > 20 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost zamućenja pozadine.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } opacityVal, err := strconv.Atoi(opacityStr) if err != nil || opacityVal < 0 || opacityVal > 80 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost prozirnosti.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -704,13 +734,13 @@ func (h *Handler) SacuvajAppPozadinaStilove(w http.ResponseWriter, r *http.Reque if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { log.Printf("stilovi app pozadine: greška pri čuvanju %s: %v", kljuc, err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } } middleware.SetFlash(w, r, h.DB, "uspeh", "Izgled pozadine aplikacije je sačuvan.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) } // SacuvajLoginPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine login stranice @@ -722,7 +752,7 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req } if err := r.ParseForm(); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čitanju forme.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -733,19 +763,19 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req blurPozadineVal, err := strconv.Atoi(blurPozadineStr) if err != nil || blurPozadineVal < 0 || blurPozadineVal > 20 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost zamućenja pozadine.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } blurKarticeVal, err := strconv.Atoi(blurKarticeStr) if err != nil || blurKarticeVal < 0 || blurKarticeVal > 20 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost zamućenja kartice.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } opacityVal, err := strconv.Atoi(opacityStr) if err != nil || opacityVal < 0 || opacityVal > 80 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost prozirnosti.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } @@ -757,13 +787,13 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { log.Printf("stilovi login pozadine: greška pri čuvanju %s: %v", kljuc, err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } } middleware.SetFlash(w, r, h.DB, "uspeh", "Izgled pozadine prijave je sačuvan.") - http.Redirect(w, r, "/podesavanja", http.StatusSeeOther) + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) } // napuniPodaciPodesavanja učitava sva podešavanja i kreira strukturu za template @@ -784,7 +814,15 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac PIB: podesavanja["pib"], LogoTip: podesavanja["logo_tip"], LogoPutanja: podesavanja["logo_putanja"], - Tema: podesavanja["tema"], + GlobalnaTema: func() string { + if v := podesavanja["globalna_tema"]; v != "" { + return v + } + if v := podesavanja["tema"]; v != "" { + return v + } + return "tamna" + }(), Sacuvano: r.URL.Query().Get("sacuvano") == "1", BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", Verzija: h.Verzija, @@ -876,28 +914,81 @@ func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) { h.renderujTemplate(w, "podesavanja_sistem", podaci) } -// PromeniTemu menja aktivnu temu i vraća na prethodnu stranicu +// PromeniTemu menja aktivnu temu i vraća na prethodnu stranicu (stara GET ruta, zadržana za kompatibilnost) func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) { tema := chi.URLParam(r, "tema") - // proveravamo da li je validna tema - validne := map[string]bool{ - "tamna": true, "svetla": true, - } + validne := map[string]bool{"tamna": true, "svetla": true} if !validne[tema] { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) return } - if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema", tema); err != nil { - http.Error(w, "Greška pri promeni teme", http.StatusInternalServerError) - return - } + _ = ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "globalna_tema", tema) - // vraćamo se na stranicu sa koje je kliknut dugmić referer := r.Header.Get("Referer") if referer == "" { referer = "/dashboard" } http.Redirect(w, r, referer, http.StatusSeeOther) } + +// PromeniGlobalnuTemu prima POST sa poljem "tema" i čuva u globalna_tema u podešavanjima +func (h *Handler) PromeniGlobalnuTemu(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + + tema := r.FormValue("tema") + validne := map[string]bool{"tamna": true, "svetla": true} + if !validne[tema] { + tema = "tamna" + } + + if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "globalna_tema", tema); err != nil { + http.Error(w, "Greška pri promeni teme", http.StatusInternalServerError) + return + } + + referer := r.Header.Get("Referer") + if referer == "" { + referer = "/dashboard" + } + http.Redirect(w, r, referer, http.StatusSeeOther) +} + +// PrimeniGlobalnuTemu čuva globalnu temu i resetuje lokalne teme svih korisnika +func (h *Handler) PrimeniGlobalnuTemu(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.globalno") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + + tema := r.FormValue("globalna_tema") + validne := map[string]bool{"tamna": true, "svetla": true} + if !validne[tema] { + tema = "tamna" + } + + if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "globalna_tema", tema); err != nil { + http.Error(w, "Greška pri čuvanju teme", http.StatusInternalServerError) + return + } + + // poništi lokalne teme svih korisnika — tema se primenjuje globalno + if _, err := h.DB.ExecContext(r.Context(), + "UPDATE korisnici SET koristi_lokalnu_temu = 0, lokalna_tema = ''", + ); err != nil { + log.Printf("PrimeniGlobalnuTemu: reset lokalnih tema: %v", err) + } + + middleware.SetFlash(w, r, h.DB, "uspeh", "Globalna tema primenjena na sve korisnike.") + http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) +} diff --git a/internal/handler/podsetnici.go b/internal/handler/podsetnici.go index ee7a67a..defdb41 100644 --- a/internal/handler/podsetnici.go +++ b/internal/handler/podsetnici.go @@ -3,16 +3,19 @@ package handler import ( "log" "net/http" + "strconv" "strings" "time" "ntech/internal/db" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" ) + // podaciPodsetnici su podaci za stranicu sa listom podsetnika type podaciPodsetnici struct { model.PodaciStranice @@ -28,10 +31,13 @@ type podaciPodsetnikForma struct { Podsetnik model.Podsetnik Greska string Izmena bool + Korisnici []model.Korisnik } // Podsetnici renderuje listu podsetnika func (h *Handler) Podsetnici(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) @@ -40,7 +46,13 @@ func (h *Handler) Podsetnici(w http.ResponseWriter, r *http.Request) { samoAktivni := r.URL.Query().Get("samo_aktivni") == "1" - lista, err := h.PodsetniciFRepo.Lista(r.Context(), db.PodsetnikFilter{SamoAktivni: samoAktivni}) + // radnik vidi samo podsetnik koji su mu dodeljeni + filter := db.PodsetnikFilter{SamoAktivni: samoAktivni} + if k.Uloga == "radnik" { + filter.KorisnikID = &k.ID + } + + lista, err := h.PodsetniciFRepo.Lista(r.Context(), filter) if err != nil { http.Error(w, "Greška pri učitavanju podsetnika", http.StatusInternalServerError) return @@ -63,6 +75,8 @@ func (h *Handler) Podsetnici(w http.ResponseWriter, r *http.Request) { // NoviPodsetnik prikazuje praznu formu za unos novog podsetnika func (h *Handler) NoviPodsetnik(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) @@ -73,46 +87,55 @@ func (h *Handler) NoviPodsetnik(w http.ResponseWriter, r *http.Request) { ps.Stranica = "podsetnici" ps.NaslovStranice = "Novi podsetnik" + var korisnici []model.Korisnik + if k.Uloga == "admin" || k.Uloga == "superadmin" { + korisnici, _ = h.KorisniciRepo.Lista(r.Context()) + } + h.renderujFormuPodsetnika(w, podaciPodsetnikForma{ PodaciStranice: ps, Izmena: false, + Korisnici: korisnici, }) } // SacuvajPodsetnik prima POST formu i upisuje novi podsetnik u bazu func (h *Handler) SacuvajPodsetnik(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return } - podsetnik, greska := parseFormuPodsetnika(r) - if greska != "" { + podsetnik, greska := parseFormuPodsetnika(r, k) + + prikaziGresku := func(poruka string) { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "podsetnici" ps.NaslovStranice = "Novi podsetnik" + var korisnici []model.Korisnik + if k.Uloga == "admin" || k.Uloga == "superadmin" { + korisnici, _ = h.KorisniciRepo.Lista(r.Context()) + } h.renderujFormuPodsetnika(w, podaciPodsetnikForma{ PodaciStranice: ps, Podsetnik: podsetnik, - Greska: greska, + Greska: poruka, Izmena: false, + Korisnici: korisnici, }) + } + + if greska != "" { + prikaziGresku(greska) return } if _, err := h.PodsetniciFRepo.Kreiraj(r.Context(), &podsetnik); err != nil { log.Printf("greška pri čuvanju podsetnika: %v", err) - podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) - ps := h.popuniPodaciStranice(r, podesavanja) - ps.Stranica = "podsetnici" - ps.NaslovStranice = "Novi podsetnik" - h.renderujFormuPodsetnika(w, podaciPodsetnikForma{ - PodaciStranice: ps, - Podsetnik: podsetnik, - Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.", - Izmena: false, - }) + prikaziGresku("Došlo je do greške pri čuvanju. Pokušajte ponovo.") return } @@ -121,6 +144,8 @@ func (h *Handler) SacuvajPodsetnik(w http.ResponseWriter, r *http.Request) { // IzmeniPodsetnik učitava podsetnik po ID-u i prikazuje popunjenu formu za izmenu func (h *Handler) IzmeniPodsetnik(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID podsetnika", http.StatusBadRequest) @@ -143,15 +168,23 @@ func (h *Handler) IzmeniPodsetnik(w http.ResponseWriter, r *http.Request) { ps.Stranica = "podsetnici" ps.NaslovStranice = "Izmeni podsetnik" + var korisnici []model.Korisnik + if k.Uloga == "admin" || k.Uloga == "superadmin" { + korisnici, _ = h.KorisniciRepo.Lista(r.Context()) + } + h.renderujFormuPodsetnika(w, podaciPodsetnikForma{ PodaciStranice: ps, Podsetnik: *podsetnik, Izmena: true, + Korisnici: korisnici, }) } // SacuvajIzmenePodsetnika prima POST formu i ažurira postojeći podsetnik u bazi func (h *Handler) SacuvajIzmenePodsetnika(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID podsetnika", http.StatusBadRequest) @@ -163,34 +196,35 @@ func (h *Handler) SacuvajIzmenePodsetnika(w http.ResponseWriter, r *http.Request return } - podsetnik, greska := parseFormuPodsetnika(r) + podsetnik, greska := parseFormuPodsetnika(r, k) podsetnik.ID = id - if greska != "" { + + prikaziGresku := func(poruka string) { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "podsetnici" ps.NaslovStranice = "Izmeni podsetnik" + var korisnici []model.Korisnik + if k.Uloga == "admin" || k.Uloga == "superadmin" { + korisnici, _ = h.KorisniciRepo.Lista(r.Context()) + } h.renderujFormuPodsetnika(w, podaciPodsetnikForma{ PodaciStranice: ps, Podsetnik: podsetnik, - Greska: greska, + Greska: poruka, Izmena: true, + Korisnici: korisnici, }) + } + + if greska != "" { + prikaziGresku(greska) return } if err := h.PodsetniciFRepo.Izmeni(r.Context(), &podsetnik); err != nil { log.Printf("greška pri čuvanju izmene podsetnika: %v", err) - podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) - ps := h.popuniPodaciStranice(r, podesavanja) - ps.Stranica = "podsetnici" - ps.NaslovStranice = "Izmeni podsetnik" - h.renderujFormuPodsetnika(w, podaciPodsetnikForma{ - PodaciStranice: ps, - Podsetnik: podsetnik, - Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.", - Izmena: true, - }) + prikaziGresku("Došlo je do greške pri čuvanju. Pokušajte ponovo.") return } @@ -237,7 +271,7 @@ func (h *Handler) ObrisiPodsetnik(w http.ResponseWriter, r *http.Request) { } // parseFormuPodsetnika čita polja iz HTTP forme, validira ih i vraća model i eventualnu grešku -func parseFormuPodsetnika(r *http.Request) (model.Podsetnik, string) { +func parseFormuPodsetnika(r *http.Request, k *model.Korisnik) (model.Podsetnik, string) { naslov := strings.TrimSpace(r.FormValue("naslov")) if naslov == "" { return model.Podsetnik{}, "Naslov je obavezan." @@ -253,12 +287,26 @@ func parseFormuPodsetnika(r *http.Request) (model.Podsetnik, string) { return model.Podsetnik{Naslov: naslov}, "Datum podsećanja nije u ispravnom formatu." } - return model.Podsetnik{ + p := model.Podsetnik{ Naslov: naslov, Napomena: strings.TrimSpace(r.FormValue("napomena")), DatumPodsecanja: datum, Tip: model.TipOpsti, - }, "" + } + + // admin/superadmin mogu dodeliti podsetnik drugom korisniku + if k.Uloga == "admin" || k.Uloga == "superadmin" { + if kidStr := strings.TrimSpace(r.FormValue("korisnik_id")); kidStr != "" { + if kid, err := strconv.ParseInt(kidStr, 10, 64); err == nil && kid > 0 { + p.KorisnikID = &kid + } + } + } else { + // radnik dobija podsetnik dodeljen sebi + p.KorisnikID = &k.ID + } + + return p, "" } // renderujFormuPodsetnika renderuje HTML šablon forme za unos ili izmenu podsetnika diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index f4b2721..8c82075 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -376,6 +376,26 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd return nalog, stavke, "" } +// StornoProdaje stornira prodajni nalog: vraća artikle na stanje i briše nalog +func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.storno") { + http.Error(w, "Nemate dozvolu za storniranje prodaje.", http.StatusForbidden) + return + } + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) + return + } + if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil { + http.Error(w, "Greška pri storniranju naloga", http.StatusInternalServerError) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.") + http.Redirect(w, r, "/prodaja", http.StatusSeeOther) +} + // renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje func (h *Handler) renderujFormuProdaje(w http.ResponseWriter, podaci PodaciFormeProdaje) { h.renderujTemplate(w, "prodaja_forma", podaci) diff --git a/internal/handler/profil.go b/internal/handler/profil.go new file mode 100644 index 0000000..201bb2b --- /dev/null +++ b/internal/handler/profil.go @@ -0,0 +1,242 @@ +package handler + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "ntech/internal/db/sqlite" + "ntech/internal/middleware" +) + +// ProfilTema prikazuje stranicu lične teme i pozadine +func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") { + http.Error(w, "Pristup odbijen.", http.StatusForbidden) + 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 + } + + svezi, err := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID) + if err != nil { + http.Error(w, "Greška pri učitavanju profila", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "profil-tema" + ps.NaslovStranice = "Moja tema" + + podaci := podaciProfilTema{ + PodaciStranice: ps, + LokalnaTema: svezi.LokalnaTema, + KoristiLokalnuTemu: svezi.KoristiLokalnuTemu, + KoristiGlobalnuTemu: !svezi.KoristiLokalnuTemu, + GlobalnaTema: podesavanja["globalna_tema"], + LokalnaPozadina: svezi.LokalnaPozadina, + LokalnaPozadinaOpacity: svezi.LokalnaPozadinaOpacity, + LokalnaPozadinaBlur: svezi.LokalnaPozadinaBlur, + LokalnaPozadinaBlurPozadine: svezi.LokalnaPozadinaBlurPozadine, + } + if podaci.LokalnaPozadinaOpacity == "" { + podaci.LokalnaPozadinaOpacity = "50" + } + if podaci.LokalnaPozadinaBlur == "" { + podaci.LokalnaPozadinaBlur = "12" + } + if podaci.LokalnaPozadinaBlurPozadine == "" { + podaci.LokalnaPozadinaBlurPozadine = "0" + } + h.renderujTemplate(w, "profil_tema", podaci) +} + +// ProfilOtpremiPozadinu prima upload lične pozadinske slike +func (h *Handler) ProfilOtpremiPozadinu(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") { + http.Error(w, "Pristup odbijen.", http.StatusForbidden) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 5<<20+4096) + if err := r.ParseMultipartForm(5 << 20); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + fajl, zaglavlje, err := r.FormFile("lokalna_pozadina") + if err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Nije odabran fajl.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + defer fajl.Close() + + if zaglavlje.Size > 5<<20 { + middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + ext := strings.ToLower(filepath.Ext(zaglavlje.Filename)) + dozvoljenoExt := map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + } + ocekivaniMime, ok := dozvoljenoExt[ext] + if !ok { + middleware.SetFlash(w, r, h.DB, "greska", "Dozvoljeni formati su JPG, PNG i WebP.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + buf := make([]byte, 512) + n, _ := fajl.Read(buf) + stvarniMime := http.DetectContentType(buf[:n]) + if !strings.HasPrefix(stvarniMime, ocekivaniMime) { + middleware.SetFlash(w, r, h.DB, "greska", "Sadržaj fajla ne odgovara odabranoj ekstenziji.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + if _, err := fajl.Seek(0, io.SeekStart); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri obradi fajla.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + // briše staru ličnu pozadinu sa diska ako postoji + svezi, _ := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID) + if svezi != nil && svezi.LokalnaPozadina != "" { + deo, _, _ := strings.Cut(svezi.LokalnaPozadina, "?") + os.Remove(filepath.Join("web/static/uploads", filepath.Base(deo))) + } + + // ime fajla: korisnik_{id}_pozadina.ext — deterministično, lako obrisati + novoIme := fmt.Sprintf("korisnik_%d_pozadina%s", k.ID, ext) + odrediste := filepath.Join("web/static/uploads", novoIme) + dst, err := os.Create(odrediste) + if err != nil { + log.Printf("ProfilOtpremiPozadinu: ne mogu kreirati fajl: %v", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + defer dst.Close() + + if _, err := io.Copy(dst, fajl); err != nil { + log.Printf("ProfilOtpremiPozadinu: greška pri kopiranju: %v", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + putanja := fmt.Sprintf("/static/uploads/%s?v=%d", novoIme, time.Now().Unix()) + + opacity := "50" + blur := "12" + blurPozadine := "0" + if svezi != nil { + if svezi.LokalnaPozadinaOpacity != "" { + opacity = svezi.LokalnaPozadinaOpacity + } + if svezi.LokalnaPozadinaBlur != "" { + blur = svezi.LokalnaPozadinaBlur + } + if svezi.LokalnaPozadinaBlurPozadine != "" { + blurPozadine = svezi.LokalnaPozadinaBlurPozadine + } + } + + if err := h.KorisniciRepo.SacuvajLokalnuPozadinu(r.Context(), k.ID, putanja, opacity, blur, blurPozadine); err != nil { + log.Printf("ProfilOtpremiPozadinu: greška pri čuvanju: %v", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) +} + +// ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika +func (h *Handler) ProfilUkloniPozadinu(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") { + http.Error(w, "Pristup odbijen.", http.StatusForbidden) + return + } + + svezi, _ := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID) + if svezi != nil && svezi.LokalnaPozadina != "" { + deo, _, _ := strings.Cut(svezi.LokalnaPozadina, "?") + os.Remove(filepath.Join("web/static/uploads", filepath.Base(deo))) + } + + if err := h.KorisniciRepo.SacuvajLokalnuPozadinu(r.Context(), k.ID, "", "50", "12", "0"); err != nil { + log.Printf("ProfilUkloniPozadinu: %v", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) +} + +// ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine +func (h *Handler) ProfilSacuvajPozadinuStilove(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") { + http.Error(w, "Pristup odbijen.", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čitanju forme.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + svezi, _ := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID) + pozadina := "" + if svezi != nil { + pozadina = svezi.LokalnaPozadina + } + + opacity := r.FormValue("lokalna_pozadina_opacity") + blur := r.FormValue("lokalna_pozadina_blur") + blurPozadineSt := r.FormValue("lokalna_pozadina_blur_pozadine") + if opacity == "" { + opacity = "50" + } + if blur == "" { + blur = "12" + } + if blurPozadineSt == "" { + blurPozadineSt = "0" + } + + if err := h.KorisniciRepo.SacuvajLokalnuPozadinu(r.Context(), k.ID, pozadina, opacity, blur, blurPozadineSt); err != nil { + log.Printf("ProfilSacuvajPozadinuStilove: %v", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + middleware.SetFlash(w, r, h.DB, "uspeh", "Podešavanja su sačuvana.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) +} diff --git a/internal/middleware/dozvole.go b/internal/middleware/dozvole.go index a672e2d..6becf02 100644 --- a/internal/middleware/dozvole.go +++ b/internal/middleware/dozvole.go @@ -45,6 +45,10 @@ var sveAkcije = []string{ "backup.pokreni", "podesavanja.login_pozadina", "podesavanja.app_pozadina", + "tema.globalno", + "tema.lokalno", + "dashboard.prihod", + "prodaja.storno", } // SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu @@ -81,7 +85,7 @@ func ImaDozvolu(uloga, akcija string) bool { "servis.izmeni", "servis.obrisi": return true // prodaja - case "prodaja.pregled", "prodaja.dodaj", "prodaja.obrisi": + case "prodaja.pregled", "prodaja.dodaj", "prodaja.obrisi", "prodaja.storno": return true // klijent case "klijent.pregled", "klijent.dodaj", @@ -105,27 +109,30 @@ func ImaDozvolu(uloga, akcija string) bool { // pozadinske slike case "podesavanja.login_pozadina", "podesavanja.app_pozadina": return true + // teme + case "tema.globalno", "tema.lokalno": + return true + // dashboard — prihod samo admin+ + case "dashboard.prihod": + return true } return false case "radnik": switch akcija { - // artikal — bez brisanja i premeštanja - case "artikal.pregled", "artikal.dodaj", "artikal.izmeni": + // artikal — samo pregled + case "artikal.pregled": return true // kategorija — samo pregled case "kategorija.pregled": return true - // nabavka — bez brisanja - case "nabavka.pregled", "nabavka.dodaj": - return true // dobavljač — bez brisanja case "dobavljac.pregled", "dobavljac.dodaj", "dobavljac.izmeni": return true // servis — bez brisanja case "servis.pregled", "servis.dodaj", "servis.izmeni": return true - // prodaja — bez brisanja + // prodaja — bez brisanja i storna case "prodaja.pregled", "prodaja.dodaj": return true // klijent — bez brisanja @@ -135,6 +142,9 @@ func ImaDozvolu(uloga, akcija string) bool { case "podsetnik.pregled", "podsetnik.dodaj", "podsetnik.izmeni", "podsetnik.obrisi": return true + // lokalna tema + case "tema.lokalno": + return true } return false } diff --git a/internal/model/korisnik.go b/internal/model/korisnik.go index 4580c6e..c171b45 100644 --- a/internal/model/korisnik.go +++ b/internal/model/korisnik.go @@ -9,8 +9,14 @@ type Korisnik struct { LozinkaHash string Uloga string // "superadmin" | "admin" | "radnik" Aktivan bool - TotpTajna string - DatumKreiranja time.Time + TotpTajna string + LokalnaTema string // "tamna" | "svetla" | "" + KoristiLokalnuTemu bool + DatumKreiranja time.Time + LokalnaPozadina string + LokalnaPozadinaOpacity string + LokalnaPozadinaBlur string + LokalnaPozadinaBlurPozadine string } // Sesija predstavlja aktivnu sesiju prijavljenog korisnika diff --git a/internal/model/podsetnik.go b/internal/model/podsetnik.go index f983874..5270ff2 100644 --- a/internal/model/podsetnik.go +++ b/internal/model/podsetnik.go @@ -14,9 +14,18 @@ type Podsetnik struct { Zavrseno bool Tip string DatumUnosa time.Time + KorisnikID *int64 // ako nil — podsetnik nije dodeljen konkretnom korisniku } // JePrekoracen vraća true ako datum podsećanja je prošao a podsetnik nije završen func (p Podsetnik) JePrekoracen() bool { return !p.Zavrseno && p.DatumPodsecanja.Before(time.Now()) } + +// KorisnikIDVal vraća vrednost KorisnikID pokazivača ili 0 ako je nil — za poređenje u šablonima +func (p Podsetnik) KorisnikIDVal() int64 { + if p.KorisnikID == nil { + return 0 + } + return *p.KorisnikID +} diff --git a/migrations/027_lokalna_tema.sql b/migrations/027_lokalna_tema.sql new file mode 100644 index 0000000..0efbfbd --- /dev/null +++ b/migrations/027_lokalna_tema.sql @@ -0,0 +1,8 @@ +-- globalna tema u podešavanjima (podrazumevano tamna) +INSERT INTO podesavanja (kljuc, vrednost) +VALUES ('globalna_tema', 'tamna') +ON CONFLICT(kljuc) DO NOTHING; + +-- lokalna tema korisnika +ALTER TABLE korisnici ADD COLUMN lokalna_tema TEXT; +ALTER TABLE korisnici ADD COLUMN koristi_lokalnu_temu INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/028_tema_pre_slike.sql b/migrations/028_tema_pre_slike.sql new file mode 100644 index 0000000..54f2ad5 --- /dev/null +++ b/migrations/028_tema_pre_slike.sql @@ -0,0 +1,3 @@ +INSERT INTO podesavanja (kljuc, vrednost) +VALUES ('tema_pre_slike', '') +ON CONFLICT(kljuc) DO NOTHING; diff --git a/migrations/029_podsetnik_korisnik.sql b/migrations/029_podsetnik_korisnik.sql new file mode 100644 index 0000000..3ca4548 --- /dev/null +++ b/migrations/029_podsetnik_korisnik.sql @@ -0,0 +1 @@ +ALTER TABLE podsetnici ADD COLUMN korisnik_id INTEGER REFERENCES korisnici(id) ON DELETE SET NULL; diff --git a/migrations/030_korisnik_pozadina.sql b/migrations/030_korisnik_pozadina.sql new file mode 100644 index 0000000..c64fa63 --- /dev/null +++ b/migrations/030_korisnik_pozadina.sql @@ -0,0 +1,3 @@ +ALTER TABLE korisnici ADD COLUMN lokalna_pozadina TEXT DEFAULT ''; +ALTER TABLE korisnici ADD COLUMN lokalna_pozadina_opacity TEXT DEFAULT '50'; +ALTER TABLE korisnici ADD COLUMN lokalna_pozadina_blur TEXT DEFAULT '12'; diff --git a/migrations/031_korisnik_pozadina_blur_pozadine.sql b/migrations/031_korisnik_pozadina_blur_pozadine.sql new file mode 100644 index 0000000..c44532f --- /dev/null +++ b/migrations/031_korisnik_pozadina_blur_pozadine.sql @@ -0,0 +1 @@ +ALTER TABLE korisnici ADD COLUMN lokalna_pozadina_blur_pozadine TEXT DEFAULT '0'; diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index d20f0ba..e9a7470 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -39,11 +39,13 @@ Magacin + {{if index .Dozvole "nabavka.pregled"}} Nabavke Nabavke + {{end}} @@ -86,11 +88,41 @@ + {{if index .Dozvole "tema.lokalno"}} +
+ + +
+ {{else}} Moj profil Moj profil + {{end}} {{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}} @@ -108,6 +140,7 @@ {{end}} + {{if ne .KorisnikUloga "radnik"}} @@ -145,6 +178,7 @@ {{end}} + {{end}}{{/* kraj ne radnik */}} + {{if index .Dozvole "dashboard.prihod"}}
@@ -45,6 +46,7 @@
{{printf "%.0f" .PrihodOvogMeseca}} din
Prihod ovog meseca
+ {{end}}
diff --git a/web/templates/stranice/magacin.html b/web/templates/stranice/magacin.html index 626c99d..d8d1617 100644 --- a/web/templates/stranice/magacin.html +++ b/web/templates/stranice/magacin.html @@ -46,8 +46,12 @@
+ {{if index .Dozvole "artikal.dodaj"}} + Novi artikal + {{end}} + {{if index .Dozvole "kategorija.pregled"}} Kategorije + {{end}}
@@ -104,9 +108,11 @@
+ {{if index $.Dozvole "artikal.izmeni"}} Izmeni + {{end}} {{if index $.Dozvole "artikal.obrisi"}} @@ -140,7 +146,9 @@ {{end}}
+ {{if index $.Dozvole "artikal.izmeni"}} Izmeni + {{end}} {{if index $.Dozvole "artikal.obrisi"}} diff --git a/web/templates/stranice/podesavanja.html b/web/templates/stranice/podesavanja.html index fd4a093..d37ec9d 100644 --- a/web/templates/stranice/podesavanja.html +++ b/web/templates/stranice/podesavanja.html @@ -442,13 +442,13 @@
-
+ {{if index .Dozvole "podesavanja.login_pozadina"}}
@@ -223,41 +224,52 @@
+ {{end}} - + {{if index .Dozvole "tema.globalno"}} +
- Tema + Globalna tema
+
Primenuje se na sve korisnike koji ne koriste svoju lokalnu temu.
-
-
-
+
+
+ {{end}}
{{end}} diff --git a/web/templates/stranice/podsetnik_forma.html b/web/templates/stranice/podsetnik_forma.html index d3e23a2..bcfefe6 100644 --- a/web/templates/stranice/podsetnik_forma.html +++ b/web/templates/stranice/podsetnik_forma.html @@ -57,6 +57,23 @@ style="width:100%;resize:vertical;">{{.Podsetnik.Napomena}}
+ {{if .Korisnici}} +
+ + +
+ {{end}} +
Odustani + + {{end}} +
{{else}} diff --git a/web/templates/stranice/profil_tema.html b/web/templates/stranice/profil_tema.html new file mode 100644 index 0000000..9495a8e --- /dev/null +++ b/web/templates/stranice/profil_tema.html @@ -0,0 +1,201 @@ +{{template "base" .}} + +{{define "naslov"}}Moja tema — NTech{{end}} + +{{define "dodatni-css"}} + +{{end}} + +{{define "sadrzaj"}} +
+ + +
+
+ Moja tema +
+ + {{if not .AppPozadina}} +
+
+
Koristi globalnu temu
+
Nema aktivne pozadinske slike
+
+ +
+ {{else}} +
+ +
+ + +
+
+
Koristi globalnu temu
+
+
+ + + +
+ + +
+
Izaberite temu:
+
+ + +
+
+ +
+ +
+
+
+ {{end}} +
+ + +
+
+ + Moja pozadinska slika +
+ + {{if .LokalnaPozadina}} +
+ Trenutna lična pozadina +
+ + +
+
+ {{end}} + +
+ +
+ + + +
+
JPG, PNG ili WebP — maksimum 5 MB
+
+ +
+ +
+
+
+
+
+ + + +
+
+
+
Artikli
+
1.284
+
+
+
Servis
+
47
+
+
+
+ +
+
+ Zamućenje pozadine + +
+ +
+
+
+ Zamućenje stakla + +
+ +
+
+
+ Zatamnjivanje + +
+ +
+
+ + + + + +
+ +
+
+ + {{if index .Dozvole "podesavanja.login_pozadina"}} + +
+
+ + Pozadinska slika prijave +
+

+ Ova slika se prikazuje na stranici za prijavu svim korisnicima. + Podešavanja su dostupna u + Podešavanjima → Izgled. +

+
+ {{end}} + +
+{{end}} diff --git a/web/templates/teme/podrazumevana/base.html b/web/templates/teme/podrazumevana/base.html index 5e006ac..915c82e 100644 --- a/web/templates/teme/podrazumevana/base.html +++ b/web/templates/teme/podrazumevana/base.html @@ -36,6 +36,11 @@ filter: blur({{.AppPozadinaBlurPozadine}}px); pointer-events: none; z-index: 0; + animation: appBgFadeIn 0.35s ease forwards; + } + @keyframes appBgFadeIn { + from { opacity: 0; } + to { opacity: 1; } } .app-overlay { position: fixed; @@ -122,19 +127,6 @@ meni.style.background = 'var(--kartica)'; }); - {{if ne .Tema "tamna"}} - - {{end}} - {{end}}