Izmena u radu tema

This commit is contained in:
2026-06-06 17:47:52 +02:00
parent 6a3500c25a
commit 5d94ea34cf
32 changed files with 1182 additions and 179 deletions
+18 -2
View File
@@ -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)
+4 -1
View File
@@ -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
}
@@ -102,6 +104,7 @@ type SesijeRepository interface {
// PodsetnikFilter definiše parametre za filtriranje liste podsetnika
type PodsetnikFilter struct {
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)
}
+78 -10
View File
@@ -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 {
+46 -15
View File
@@ -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)
}
+57
View File
@@ -31,6 +31,21 @@ type podaciAdminProfil struct {
TotpTajna string
TotpQR template.URL
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
@@ -287,9 +302,51 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
h.renderujTemplate(w, "admin_profil", podaciAdminProfil{
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())
+11 -1
View File
@@ -5,7 +5,9 @@ import (
"net/http"
"time"
appdb "ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
)
@@ -47,12 +49,16 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
log.Printf("dashboard: aktivni servisi: %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,
"SELECT COUNT(*) FROM artikli WHERE kolicina <= kolicina_min",
@@ -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
+57 -1
View File
@@ -74,24 +74,65 @@ 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)
// 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"
@@ -104,6 +145,21 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
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
}
+1
View File
@@ -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",
}
+18
View File
@@ -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)
+142 -51
View File
@@ -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,
@@ -259,6 +267,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
"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)
}
+78 -30
View File
@@ -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
+20
View File
@@ -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)
+242
View File
@@ -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)
}
+17 -7
View File
@@ -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
}
+6
View File
@@ -10,7 +10,13 @@ type Korisnik struct {
Uloga string // "superadmin" | "admin" | "radnik"
Aktivan bool
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
+9
View File
@@ -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
}
+8
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
INSERT INTO podesavanja (kljuc, vrednost)
VALUES ('tema_pre_slike', '')
ON CONFLICT(kljuc) DO NOTHING;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE podsetnici ADD COLUMN korisnik_id INTEGER REFERENCES korisnici(id) ON DELETE SET NULL;
+3
View File
@@ -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';
@@ -0,0 +1 @@
ALTER TABLE korisnici ADD COLUMN lokalna_pozadina_blur_pozadine TEXT DEFAULT '0';
+34
View File
@@ -39,11 +39,13 @@
<span class="nav-tooltip">Magacin</span>
</a>
{{if index .Dozvole "nabavka.pregled"}}
<a href="/nabavke" class="nav-stavka {{if eq .Stranica "nabavke"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span>Nabavke</span>
<span class="nav-tooltip">Nabavke</span>
</a>
{{end}}
<a href="/servis" class="nav-stavka {{if eq .Stranica "servis"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
@@ -86,11 +88,41 @@
<div class="nav-separator"></div>
<div class="nav-oznaka">Nalog</div>
{{if index .Dozvole "tema.lokalno"}}
<div x-data="{ otvoren: {{if or (eq .Stranica "profil") (eq .Stranica "profil-tema")}}true{{else}}false{{end}} }">
<button type="button" @click="otvoren = !otvoren"
class="nav-stavka {{if or (eq .Stranica "profil") (eq .Stranica "profil-tema")}}aktivan{{end}}"
style="width:100%;background:none;border:none;cursor:pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>Moj profil</span>
<span class="nav-strelica">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="`transition:transform 0.2s;transform:${otvoren?'rotate(180deg)':'rotate(0deg)'}`">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
<span class="nav-tooltip">Moj profil</span>
</button>
<div class="nav-podmeni" x-show="otvoren" x-transition>
<a href="/admin/profil" class="nav-stavka nav-podstavka {{if eq .Stranica "profil"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>Opšte</span>
<span class="nav-tooltip">Opšte</span>
</a>
<a href="/profil/tema" class="nav-stavka nav-podstavka {{if eq .Stranica "profil-tema"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
<span>Tema</span>
<span class="nav-tooltip">Tema</span>
</a>
</div>
</div>
{{else}}
<a href="/admin/profil" class="nav-stavka {{if eq .Stranica "profil"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>Moj profil</span>
<span class="nav-tooltip">Moj profil</span>
</a>
{{end}}
{{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}}
<a href="/admin/korisnici" class="nav-stavka {{if eq .Stranica "admin"}}aktivan{{end}}">
@@ -108,6 +140,7 @@
</a>
{{end}}
{{if ne .KorisnikUloga "radnik"}}
<div class="nav-separator"></div>
<div class="nav-oznaka">Sistem</div>
@@ -145,6 +178,7 @@
</div>
</div>
{{end}}
{{end}}{{/* kraj ne radnik */}}
</nav>
<div class="sidebar-dno">
+14 -2
View File
@@ -12,10 +12,22 @@
<span class="topbar-naslov">{{.NaslovStranice}}</span>
{{if not .AppPozadina}}
<div class="topbar-teme" style="display:flex;align-items:center;gap:8px;">
<a href="/tema/tamna" class="tema-krug {{if eq .Tema "tamna"}}tema-krug-aktivan{{end}}" style="background:#1a1d27;" title="Tamna"></a>
<a href="/tema/svetla" class="tema-krug {{if eq .Tema "svetla"}}tema-krug-aktivan{{end}}" style="background:#f0f2f5;border:1px solid #e2e6ed;" title="Svetla"></a>
<form method="POST" action="/podesavanja/tema" style="margin:0;padding:0;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="tema" value="tamna">
<button type="submit" class="tema-krug {{if eq .Tema "tamna"}}tema-krug-aktivan{{end}}"
style="background:#1a1d27;border:none;padding:0;cursor:pointer;" title="Tamna"></button>
</form>
<form method="POST" action="/podesavanja/tema" style="margin:0;padding:0;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="tema" value="svetla">
<button type="submit" class="tema-krug {{if eq .Tema "svetla"}}tema-krug-aktivan{{end}}"
style="background:#f0f2f5;border:1px solid #e2e6ed;padding:0;cursor:pointer;" title="Svetla"></button>
</form>
</div>
{{end}}
<div style="position:relative;" id="avatar-wrapper">
<div class="avatar-korisnik" id="avatar-dugme" style="cursor:pointer;" title="{{.Korisnik}}">
+14
View File
@@ -8,6 +8,7 @@
.animiraj:nth-child(2) { animation-delay: 0.16s; }
.animiraj:nth-child(3) { animation-delay: 0.22s; }
.animiraj:nth-child(4) { animation-delay: 0.28s; }
.animiraj:nth-child(5) { animation-delay: 0.34s; }
</style>
{{end}}
@@ -115,5 +116,18 @@
</div>
{{if index .Dozvole "tema.lokalno"}}
<div class="kartica animiraj" style="background:var(--pozadina);border:0.5px solid var(--ivica);">
<div style="display:flex;align-items:center;gap:10px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
<span style="font-size:14px;color:var(--tekst-glavni);">
Podešavanja teme i pozadine nalaze se na
<a href="/profil/tema" style="color:var(--sb-akcent);text-decoration:none;font-weight:500;">stranici Tema</a>.
</span>
</div>
</div>
{{end}}
</div>
{{end}}
+2
View File
@@ -38,6 +38,7 @@
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:4px;">Aktivnih servisa</div>
</div>
{{if index .Dozvole "dashboard.prihod"}}
<div class="kartica dash-stat animiraj">
<div style="width:36px;height:36px;border-radius:8px;background:#fff7ed;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
@@ -45,6 +46,7 @@
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.0f" .PrihodOvogMeseca}} din</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:4px;">Prihod ovog meseca</div>
</div>
{{end}}
<div class="kartica dash-stat animiraj">
<div style="width:36px;height:36px;border-radius:8px;background:#fef2f2;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
+8
View File
@@ -46,8 +46,12 @@
<!-- dugmad -->
<div style="display:flex;gap:10px;flex-wrap:wrap;">
{{if index .Dozvole "artikal.dodaj"}}
<a href="/magacin/novi" class="btn-primarno">+ Novi artikal</a>
{{end}}
{{if index .Dozvole "kategorija.pregled"}}
<a href="/magacin/kategorije" class="btn-primarno">Kategorije</a>
{{end}}
</div>
<!-- pretraga i filteri -->
@@ -104,9 +108,11 @@
</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
{{if index $.Dozvole "artikal.izmeni"}}
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">
Izmeni
</a>
{{end}}
{{if index $.Dozvole "artikal.obrisi"}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
@@ -140,7 +146,9 @@
{{end}}
</div>
<div style="display:flex;gap:8px;flex-shrink:0;">
{{if index $.Dozvole "artikal.izmeni"}}
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">Izmeni</a>
{{end}}
{{if index $.Dozvole "artikal.obrisi"}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
+4 -4
View File
@@ -442,13 +442,13 @@
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:8px;">Izaberi temu</label>
<div class="teme-grid" style="display:flex;gap:10px;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .Tema "tamna"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="tema" value="tamna" {{if eq .Tema "tamna"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .GlobalnaTema "tamna"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="globalna_tema" value="tamna" {{if eq .GlobalnaTema "tamna"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<div style="width:16px;height:16px;border-radius:50%;background:#1a1d27;flex-shrink:0;"></div>
<span style="font-size:13px;color:var(--tekst-glavni);">Tamna</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .Tema "svetla"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="tema" value="svetla" {{if eq .Tema "svetla"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .GlobalnaTema "svetla"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="globalna_tema" value="svetla" {{if eq .GlobalnaTema "svetla"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<div style="width:16px;height:16px;border-radius:50%;background:#f0f2f5;border:0.5px solid #e2e6ed;flex-shrink:0;"></div>
<span style="font-size:13px;color:var(--tekst-glavni);">Svetla</span>
</label>
+21 -9
View File
@@ -136,6 +136,7 @@
</div>
{{if index .Dozvole "podesavanja.login_pozadina"}}
<!-- pozadinska slika login stranice -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
@@ -223,41 +224,52 @@
</div><!-- /x-data -->
</div>
{{end}}
<!-- tema -->
{{if index .Dozvole "tema.globalno"}}
<!-- globalna tema -->
<form method="POST" action="/podesavanja/sacuvaj">
<input type="hidden" name="_next" value="/admin/podesavanja/izgled">
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Tema</span>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Globalna tema</span>
</div>
<div style="font-size:13px;color:var(--tekst-sporedni);margin-bottom:12px;">Primenuje se na sve korisnike koji ne koriste svoju lokalnu temu.</div>
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:8px;">Izaberi temu</label>
<div class="teme-grid" style="display:flex;gap:10px;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .Tema "tamna"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="tema" value="tamna" {{if eq .Tema "tamna"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .GlobalnaTema "tamna"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="globalna_tema" value="tamna" {{if eq .GlobalnaTema "tamna"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<div style="width:16px;height:16px;border-radius:50%;background:#1a1d27;flex-shrink:0;"></div>
<span style="font-size:13px;color:var(--tekst-glavni);">Tamna</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .Tema "svetla"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="tema" value="svetla" {{if eq .Tema "svetla"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .GlobalnaTema "svetla"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="globalna_tema" value="svetla"
{{if eq .GlobalnaTema "svetla"}}checked{{end}}
style="accent-color:var(--sb-akcent);">
<div style="width:16px;height:16px;border-radius:50%;background:#f0f2f5;border:0.5px solid #e2e6ed;flex-shrink:0;"></div>
<span style="font-size:13px;color:var(--tekst-glavni);">Svetla</span>
</label>
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-top:20px;">
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
<button type="submit" formaction="/podesavanja/tema/globalno"
style="background:transparent;color:var(--sb-akcent);border:0.5px solid var(--sb-akcent);padding:10px 24px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.75'" onmouseout="this.style.opacity='1'"
title="Primeni ovu temu na sve korisnike, poništi njihove lokalne teme">
Primeni globalno
</button>
<button type="submit"
style="background:var(--sb-akcent);color:#fff;border:none;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">
Sačuvaj izgled
Sačuvaj
</button>
</div>
</div>
</form>
{{end}}
</div>
{{end}}
@@ -57,6 +57,23 @@
style="width:100%;resize:vertical;">{{.Podsetnik.Napomena}}</textarea>
</div>
{{if .Korisnici}}
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">
Dodeli korisniku
</label>
<select name="korisnik_id" style="width:100%;">
<option value="">— Nije dodeljeno —</option>
{{range .Korisnici}}
<option value="{{.ID}}"
{{if eq $.Podsetnik.KorisnikIDVal .ID}}selected{{end}}>
{{.KorisnickoIme}} ({{.Uloga}})
</option>
{{end}}
</select>
</div>
{{end}}
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:4px;">
<a href="/podsetnici" class="btn-sekundarno">Odustani</a>
<button type="submit" class="btn-primarno">
+12 -3
View File
@@ -89,9 +89,18 @@
{{printf "%.2f" .Ukupno}} din
</td>
<td style="padding:12px 16px;text-align:center;">
<a href="/prodaja/{{.ID}}" class="btn-primarno-malo">
Detalji
</a>
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
<a href="/prodaja/{{.ID}}" class="btn-primarno-malo">Detalji</a>
{{if index $.Dozvole "prodaja.storno"}}
<form method="POST" action="/prodaja/storno/{{.ID}}" style="margin:0;padding:0;">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da stornirate ovaj nalog? Artikli će biti vraćeni na stanje.">
Storno
</button>
</form>
{{end}}
</div>
</td>
</tr>
{{else}}
+201
View File
@@ -0,0 +1,201 @@
{{template "base" .}}
{{define "naslov"}}Moja tema — NTech{{end}}
{{define "dodatni-css"}}
<style>
.animiraj:nth-child(1) { animation-delay: 0.10s; }
.animiraj:nth-child(2) { animation-delay: 0.16s; }
.animiraj:nth-child(3) { animation-delay: 0.22s; }
</style>
{{end}}
{{define "sadrzaj"}}
<div style="width:100%;max-width:100%;">
<!-- kartica: moja tema -->
<div class="kartica animiraj" x-data="{
globalnu: {{if .KoristiGlobalnuTemu}}true{{else}}false{{end}},
tema: '{{if .LokalnaTema}}{{.LokalnaTema}}{{else}}tamna{{end}}'
}" style="margin-bottom:16px;">
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
Moja tema
</div>
{{if not .AppPozadina}}
<div style="display:flex;align-items:center;justify-content:space-between;opacity:0.5;cursor:not-allowed;"
title="Administrator nije postavio pozadinsku sliku">
<div>
<div style="font-size:14px;color:var(--tekst-glavni);font-weight:500;">Koristi globalnu temu</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">Nema aktivne pozadinske slike</div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;pointer-events:none;">
<span style="position:absolute;inset:0;border-radius:24px;background:var(--ivica);"></span>
<span style="position:absolute;left:2px;top:2px;width:20px;height:20px;background:#fff;border-radius:50%;"></span>
</label>
</div>
{{else}}
<form method="POST" action="/profil/tema">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div style="display:flex;flex-direction:column;gap:14px;">
<!-- svič: globalnu=true → koristi globalnu; globalnu=false → koristi lokalnu -->
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<div style="font-size:14px;color:var(--tekst-glavni);font-weight:500;">Koristi globalnu temu</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;" x-text="globalnu ? 'Primenjuje se globalna tema sistema' : 'Primenjuje se vaša lična tema'"></div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;cursor:pointer;">
<input type="checkbox" style="opacity:0;width:0;height:0;position:absolute;"
x-model="globalnu">
<span :style="'position:absolute;inset:0;border-radius:24px;transition:0.2s;background:' + (globalnu ? 'var(--sb-akcent)' : 'var(--ivica)')"></span>
<span :style="'position:absolute;left:' + (globalnu ? '22px' : '2px') + ';top:2px;width:20px;height:20px;background:#fff;border-radius:50%;transition:0.2s;'"></span>
</label>
<!-- globalnu=true → koristi_lokalnu_temu=0; globalnu=false → koristi_lokalnu_temu=1 -->
<input type="hidden" name="koristi_lokalnu_temu" :value="globalnu ? '0' : '1'">
</div>
<!-- radio dugmići vidljivi samo kada je lokalna tema aktivna -->
<div x-show="!globalnu" x-transition style="padding:12px;background:var(--pozadina);border-radius:8px;border:0.5px solid var(--ivica);">
<div style="font-size:13px;color:var(--tekst-sporedni);margin-bottom:10px;">Izaberite temu:</div>
<div style="display:flex;gap:16px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;color:var(--tekst-glavni);">
<input type="radio" name="lokalna_tema" value="tamna" x-model="tema">
Tamna
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;color:var(--tekst-glavni);">
<input type="radio" name="lokalna_tema" value="svetla" x-model="tema">
Svetla
</label>
</div>
</div>
<div>
<button type="submit"
style="padding:9px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">
Sačuvaj
</button>
</div>
</div>
</form>
{{end}}
</div>
<!-- kartica: moja pozadinska slika -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Moja pozadinska slika</span>
</div>
{{if .LokalnaPozadina}}
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">
<img src="{{.LokalnaPozadina}}" alt="Trenutna lična pozadina"
style="width:120px;height:70px;object-fit:cover;border-radius:6px;border:0.5px solid var(--ivica);flex-shrink:0;">
<form method="POST" action="/profil/pozadina/ukloni">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit"
style="padding:7px 14px;background:transparent;border:0.5px solid #fca5a5;border-radius:8px;color:#dc2626;font-size:13px;cursor:pointer;"
data-potvrda="Ukloniti ličnu pozadinsku sliku?">
Ukloni
</button>
</form>
</div>
{{end}}
<form method="POST" action="/profil/pozadina" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<input type="file" id="lok-bg-file" name="lokalna_pozadina" accept=".jpg,.jpeg,.png,.webp"
style="display:none;">
<label for="lok-bg-file"
style="display:inline-flex;align-items:center;padding:8px 14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;user-select:none;">
Odaberi sliku
</label>
<button type="submit"
style="padding:8px 16px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;">
Otpremi sliku
</button>
</div>
<div style="font-size:12px;color:var(--tekst-sporedni);">JPG, PNG ili WebP — maksimum 5 MB</div>
</form>
<div x-data="{ blur: {{.LokalnaPozadinaBlur}}, opacity: {{.LokalnaPozadinaOpacity}}, blurPozadine: {{.LokalnaPozadinaBlurPozadine}} }"
style="margin-top:20px;">
<div style="position:relative;width:100%;height:180px;border-radius:8px;overflow:hidden;">
<div :style="`position:absolute;inset:0;{{if .LokalnaPozadina}}background:url('{{.LokalnaPozadina}}') center/cover;{{else}}background:#1a2033;{{end}}z-index:0;filter:blur(${blurPozadine}px);-webkit-filter:blur(${blurPozadine}px);transform:scale(1.05);`"></div>
<div :style="`position:absolute;inset:0;z-index:1;pointer-events:none;background:rgba(0,0,0,${opacity/100})`"></div>
<div :style="`position:absolute;top:0;left:0;bottom:0;z-index:2;width:56px;background:rgba(255,255,255,0.10);backdrop-filter:blur(${blur}px);-webkit-backdrop-filter:blur(${blur}px);border-right:1px solid rgba(255,255,255,0.15);display:flex;flex-direction:column;align-items:center;padding-top:10px;gap:12px`">
<div style="width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.25);flex-shrink:0;"></div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.85)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.85)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.85)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>
</div>
<div style="position:absolute;top:12px;left:68px;right:12px;z-index:2;display:flex;flex-direction:column;gap:7px;">
<div :style="`height:36px;border-radius:6px;background:rgba(255,255,255,0.10);backdrop-filter:blur(${blur}px);-webkit-backdrop-filter:blur(${blur}px);border:1px solid rgba(255,255,255,0.15);display:flex;flex-direction:column;justify-content:center;padding:0 10px`">
<div style="display:block;font-size:9px;color:white;opacity:0.7;line-height:1.2;">Artikli</div>
<div style="display:block;font-size:13px;color:white;font-weight:500;line-height:1.3;">1.284</div>
</div>
<div :style="`height:36px;border-radius:6px;background:rgba(255,255,255,0.10);backdrop-filter:blur(${blur}px);-webkit-backdrop-filter:blur(${blur}px);border:1px solid rgba(255,255,255,0.15);display:flex;flex-direction:column;justify-content:center;padding:0 10px`">
<div style="display:block;font-size:9px;color:white;opacity:0.7;line-height:1.2;">Servis</div>
<div style="display:block;font-size:13px;color:white;font-weight:500;line-height:1.3;">47</div>
</div>
</div>
</div>
<div style="margin-top:16px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:13px;color:var(--tekst-sporedni);">Zamućenje pozadine</span>
<span style="font-size:13px;color:var(--tekst-glavni);font-weight:500;" x-text="blurPozadine + 'px'"></span>
</div>
<input type="range" x-model.number="blurPozadine" min="0" max="20" step="1"
style="width:100%;accent-color:var(--sb-akcent);cursor:pointer;">
</div>
<div style="margin-top:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:13px;color:var(--tekst-sporedni);">Zamućenje stakla</span>
<span style="font-size:13px;color:var(--tekst-glavni);font-weight:500;" x-text="blur + 'px'"></span>
</div>
<input type="range" x-model.number="blur" min="0" max="20" step="1"
style="width:100%;accent-color:var(--sb-akcent);cursor:pointer;">
</div>
<div style="margin-top:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:13px;color:var(--tekst-sporedni);">Zatamnjivanje</span>
<span style="font-size:13px;color:var(--tekst-glavni);font-weight:500;" x-text="opacity + '%'"></span>
</div>
<input type="range" x-model.number="opacity" min="0" max="80" step="1"
style="width:100%;accent-color:var(--sb-akcent);cursor:pointer;">
</div>
<form method="POST" action="/profil/pozadina/stilovi" style="margin-top:16px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="lokalna_pozadina_opacity" :value="opacity">
<input type="hidden" name="lokalna_pozadina_blur" :value="blur">
<input type="hidden" name="lokalna_pozadina_blur_pozadine" :value="blurPozadine">
<button type="submit"
style="padding:9px 22px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">
Sačuvaj izgled
</button>
</form>
</div><!-- /x-data -->
</div>
{{if index .Dozvole "podesavanja.login_pozadina"}}
<!-- kartica: pozadinska slika prijave — samo za admin/superadmin -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Pozadinska slika prijave</span>
</div>
<p style="font-size:13px;color:var(--tekst-sporedni);margin:0 0 12px;">
Ova slika se prikazuje na stranici za prijavu svim korisnicima.
Podešavanja su dostupna u
<a href="/admin/podesavanja/izgled" style="color:var(--sb-akcent);text-decoration:none;">Podešavanjima → Izgled</a>.
</p>
</div>
{{end}}
</div>
{{end}}
+5 -13
View File
@@ -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)';
});
</script>
{{if ne .Tema "tamna"}}
<script>window.location.replace('/tema/tamna');</script>
{{end}}
<script>
document.addEventListener('DOMContentLoaded', function() {
var poruka = 'Uklonite pozadinsku sliku da biste mogli da menjate temu';
document.querySelectorAll('.tema-krug').forEach(function(el) {
el.style.pointerEvents = 'none';
el.style.opacity = '0.4';
el.title = poruka;
});
});
</script>
{{end}}
</head>
<body>