diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index a1d179d..92185d2 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -253,6 +253,7 @@ func main() { r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/logo", h.OtpremiLogo) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/logo/ukloni", h.UkloniLogo) r.With(doz("podesavanja.login_pozadina")).Post("/podesavanja/login-pozadina", h.OtpremiLoginPozadinu) r.With(doz("podesavanja.login_pozadina")).Post("/podesavanja/login-pozadina/ukloni", h.UkloniLoginPozadinu) r.With(doz("podesavanja.login_pozadina")).Post("/podesavanja/login-pozadina/stilovi", h.SacuvajLoginPozadinaStilove) @@ -359,6 +360,8 @@ func main() { r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu) r.With(doz("tema.lokalno")).Post("/profil/pozadina/ukloni", h.ProfilUkloniPozadinu) r.With(doz("tema.lokalno")).Post("/profil/pozadina/stilovi", h.ProfilSacuvajPozadinuStilove) + r.Post("/profil/avatar", h.ProfilOtpremiAvatar) + r.Post("/profil/avatar/ukloni", h.ProfilUkloniAvatar) }) slog.Info("NTech pokrenut", "port", port) diff --git a/internal/db/repository.go b/internal/db/repository.go index aba096c..f7d9893 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -149,6 +149,7 @@ type KorisniciRepository interface { 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, glassOpacity string) error + SacuvajAvatar(ctx context.Context, id int64, putanja string) error PostojiIjedan(ctx context.Context) (bool, error) Obrisi(ctx context.Context, id int64) error } diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index 6ed7f68..d7c9bbb 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -34,8 +34,9 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod args := []any{} if filter.Pretraga != "" { - upit += " AND a.naziv LIKE ?" - args = append(args, "%"+filter.Pretraga+"%") + upit += " AND (a.naziv LIKE ? OR a.lokacija LIKE ? OR k.naziv LIKE ?)" + t := "%" + filter.Pretraga + "%" + args = append(args, t, t, t) } if filter.KategorijaID != nil { diff --git a/internal/db/sqlite/izvestaj.go b/internal/db/sqlite/izvestaj.go index 79d08c6..4999d98 100644 --- a/internal/db/sqlite/izvestaj.go +++ b/internal/db/sqlite/izvestaj.go @@ -75,7 +75,7 @@ func (r *sqliteIzvestajRepo) PoslednjiServisi(ctx context.Context, limit int) ([ func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT naziv, kolicina FROM artikli + SELECT naziv, kolicina, kolicina_min FROM artikli WHERE kolicina <= kolicina_min ORDER BY kolicina ASC LIMIT ?`, limit) if err != nil { @@ -85,7 +85,7 @@ func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]m var lista []model.ZalihaRed for rows.Next() { var z model.ZalihaRed - if err := rows.Scan(&z.Naziv, &z.Kolicina); err != nil { + if err := rows.Scan(&z.Naziv, &z.Kolicina, &z.KolicinaMin); err != nil { return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err) } lista = append(lista, z) diff --git a/internal/db/sqlite/korisnici.go b/internal/db/sqlite/korisnici.go index 23e591d..430e29c 100644 --- a/internal/db/sqlite/korisnici.go +++ b/internal/db/sqlite/korisnici.go @@ -17,41 +17,48 @@ type sqliteKorisniciRepo struct { kljuc []byte } -// dodeliOpcijeKorisnika popunjava bool i opciona polja korisnika iz skeniranih -// NULL vrednosti — deljeno između skeniraiKorisnika (jedan red) i Lista (više redova) -func dodeliOpcijeKorisnika(k *model.Korisnik, aktivan, koristiLokalnuTemu int, - lokalnaTema, lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, - lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity sql.NullString, - datumKreiranja time.Time) { - k.Aktivan = aktivan == 1 - k.LokalnaTema = lokalnaTema.String - k.KoristiLokalnuTemu = koristiLokalnuTemu == 1 - k.DatumKreiranja = datumKreiranja - k.LokalnaPozadina = lokalnaPozadina.String - k.LokalnaPozadinaOpacity = lokalnaPozadinaOpacity.String - k.LokalnaPozadinaBlur = lokalnaPozadinaBlur.String - k.LokalnaPozadinaBlurPozadine = lokalnaPozadinaBlurPozadine.String - k.LokalnaPozadinaGlassOpacity = lokalnaPozadinaGlassOpacity.String +// korisnikOpcije drži NULL vrednosti skeniranih opcionalnih kolona korisnika; +// dodavanje novog polja zahteva izmenu samo ovog struct-a i relevantnih Scan poziva. +type korisnikOpcije struct { + aktivan int + koristiLokalnuTemu int + datumKreiranja time.Time + lokalnaTema sql.NullString + lokalnaPozadina sql.NullString + lokalnaPozadinaOpacity sql.NullString + lokalnaPozadinaBlur sql.NullString + lokalnaPozadinaBlurPozadine sql.NullString + lokalnaPozadinaGlassOpacity sql.NullString + avatarPutanja sql.NullString +} + +// dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik +func dodeliOpcijeKorisnika(k *model.Korisnik, o korisnikOpcije) { + k.Aktivan = o.aktivan == 1 + k.KoristiLokalnuTemu = o.koristiLokalnuTemu == 1 + k.DatumKreiranja = o.datumKreiranja + k.LokalnaTema = o.lokalnaTema.String + k.LokalnaPozadina = o.lokalnaPozadina.String + k.LokalnaPozadinaOpacity = o.lokalnaPozadinaOpacity.String + k.LokalnaPozadinaBlur = o.lokalnaPozadinaBlur.String + k.LokalnaPozadinaBlurPozadine = o.lokalnaPozadinaBlurPozadine.String + k.LokalnaPozadinaGlassOpacity = o.lokalnaPozadinaGlassOpacity.String + k.AvatarPutanja = o.avatarPutanja.String } // skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, error) { k := &model.Korisnik{} - var aktivan, koristiLokalnuTemu int - var lokalnaTema sql.NullString - var lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity sql.NullString - var datumKreiranja time.Time + var o korisnikOpcije if err := row.Scan( - &k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, - &lokalnaTema, &koristiLokalnuTemu, &datumKreiranja, - &lokalnaPozadina, &lokalnaPozadinaOpacity, &lokalnaPozadinaBlur, - &lokalnaPozadinaBlurPozadine, &lokalnaPozadinaGlassOpacity, + &k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &o.aktivan, &k.TotpTajna, + &o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja, + &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, + &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, ); err != nil { return nil, err } - dodeliOpcijeKorisnika(k, aktivan, koristiLokalnuTemu, lokalnaTema, - lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, - lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, datumKreiranja) + dodeliOpcijeKorisnika(k, o) return k, nil } @@ -91,7 +98,7 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme 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'), - COALESCE(lokalna_pozadina_glass_opacity, '10') + COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '') FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme) k, err := skeniraiKorisnika(row) if err != nil { @@ -107,7 +114,7 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model 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'), - COALESCE(lokalna_pozadina_glass_opacity, '10') + COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '') FROM korisnici WHERE id = ?`, id) k, err := skeniraiKorisnika(row) if err != nil { @@ -123,7 +130,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro 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'), - COALESCE(lokalna_pozadina_glass_opacity, '10') + COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '') FROM korisnici ORDER BY datum_kreiranja ASC`) if err != nil { return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) @@ -132,25 +139,31 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro var lista []model.Korisnik for rows.Next() { var k model.Korisnik - var aktivan, koristiLokalnuTemu int - var lokalnaTema sql.NullString - var lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity sql.NullString - var datumKreiranja time.Time - if err := rows.Scan(&k.ID, &k.KorisnickoIme, &k.Uloga, &aktivan, &k.TotpTajna, - &lokalnaTema, &koristiLokalnuTemu, &datumKreiranja, - &lokalnaPozadina, &lokalnaPozadinaOpacity, &lokalnaPozadinaBlur, &lokalnaPozadinaBlurPozadine, - &lokalnaPozadinaGlassOpacity); err != nil { + var o korisnikOpcije + if err := rows.Scan( + &k.ID, &k.KorisnickoIme, &k.Uloga, &o.aktivan, &k.TotpTajna, + &o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja, + &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, + &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, + ); err != nil { return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) } - dodeliOpcijeKorisnika(&k, aktivan, koristiLokalnuTemu, lokalnaTema, - lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, - lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, datumKreiranja) + dodeliOpcijeKorisnika(&k, o) r.desifrujTotpTajnu(&k) lista = append(lista, k) } return lista, nil } +func (r *sqliteKorisniciRepo) SacuvajAvatar(ctx context.Context, id int64, putanja string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE korisnici SET avatar_putanja = ? WHERE id = ?`, putanja, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.SacuvajAvatar: %w", err) + } + return nil +} + func (r *sqliteKorisniciRepo) SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error { _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET lokalna_pozadina = ?, lokalna_pozadina_opacity = ?, lokalna_pozadina_blur = ?, lokalna_pozadina_blur_pozadine = ?, lokalna_pozadina_glass_opacity = ? WHERE id = ?`, diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index eb5622e..30acb91 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -95,7 +95,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { } else { for _, z := range redovi { boja := "#f97316" - if z.Kolicina == 0 { + if z.Kolicina == 0 || (z.KolicinaMin > 0 && z.Kolicina <= z.KolicinaMin/2) { boja = "#dc2626" } kriticneZalihe = append(kriticneZalihe, model.StavkaZalihe{ diff --git a/internal/handler/handler.go b/internal/handler/handler.go index b506a4d..717e218 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -161,12 +161,12 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s tema := "tamna" ps := model.PodaciStranice{ - Tema: tema, - NazivFirme: podesavanja["naziv_firme"], - Podnazlov: podesavanja["podnazlov"], - LogoTip: podesavanja["logo_tip"], - LogoPutanja: podesavanja["logo_putanja"], - Korisnik: "Admin", + Tema: tema, + NazivFirme: podesavanja["naziv_firme"], + Podnazlov: podesavanja["podnazlov"], + LogoPutanja: podesavanja["logo_putanja"], + TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1", + Korisnik: "Admin", } var korisnik *model.Korisnik if k := middleware.KorisnikIzKonteksta(r.Context()); k != nil { @@ -174,6 +174,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s ps.Korisnik = k.KorisnickoIme ps.KorisnikIme = k.KorisnickoIme ps.KorisnikUloga = k.Uloga + ps.AvatarPutanja = k.AvatarPutanja ps.Dozvole = h.DozvoleRepo.SveDozvole(r.Context(), k.Uloga) // lokalna tema korisnika — primenjuje se uvek kada je postavljena, bez obzira na KoristiLokalnuTemu if k.LokalnaTema != "" { diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 0bd6033..48c48d4 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -28,8 +28,8 @@ type PodaciPodesavanja struct { Adresa string Telefon string PIB string - LogoTip string - LogoPutanja string + LogoPutanja string + TopbarLogoSlika bool // profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale FirmaPravniOblik string FirmaPdvObveznik string @@ -81,8 +81,8 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) { Adresa: podesavanja["adresa"], Telefon: podesavanja["telefon"], PIB: podesavanja["pib"], - LogoTip: podesavanja["logo_tip"], LogoPutanja: podesavanja["logo_putanja"], + TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1", Sacuvano: r.URL.Query().Get("sacuvano") == "1", BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", Verzija: h.Verzija, @@ -233,13 +233,19 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { return } + // checkbox-i: šalju vrednost samo kada su čekirani, pa ih uvek eksplicitno čitamo + topbarLogoSlika := "0" + if r.FormValue("topbar_logo_slika") == "1" { + topbarLogoSlika = "1" + } + polja := map[string]string{ - "naziv_firme": r.FormValue("naziv_firme"), - "podnazlov": r.FormValue("podnazlov"), - "adresa": r.FormValue("adresa"), - "telefon": r.FormValue("telefon"), - "pib": r.FormValue("pib"), - "logo_tip": r.FormValue("logo_tip"), + "naziv_firme": r.FormValue("naziv_firme"), + "podnazlov": r.FormValue("podnazlov"), + "adresa": r.FormValue("adresa"), + "telefon": r.FormValue("telefon"), + "pib": r.FormValue("pib"), + "topbar_logo_slika": topbarLogoSlika, // profil firme (Faza 0) — radio dugmad uvek šalju vrednost, pa se uredno čuvaju "firma_pravni_oblik": r.FormValue("firma_pravni_oblik"), "firma_pdv_obveznik": r.FormValue("firma_pdv_obveznik"), @@ -412,6 +418,21 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther) } +// UkloniLogo briše logo fajl i čisti putanju iz podešavanja +func (h *Handler) UkloniLogo(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { + return + } + stari, _ := filepath.Glob("web/static/uploads/logo.*") + for _, s := range stari { + os.Remove(s) + } + if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", ""); err != nil { + slog.Error("ukloni logo: greška pri čuvanju", "error", err) + } + http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther) +} + // generisiImeUploada vraća slučajno hex ime (16 bajtova) sa datom ekstenzijom func generisiImeUploada(ext string) (string, error) { buf := make([]byte, 16) @@ -624,8 +645,8 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac Adresa: podesavanja["adresa"], Telefon: podesavanja["telefon"], PIB: podesavanja["pib"], - LogoTip: podesavanja["logo_tip"], LogoPutanja: podesavanja["logo_putanja"], + TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1", FirmaPravniOblik: vrednostIliDefault(podesavanja, "firma_pravni_oblik", "pausalac"), FirmaPdvObveznik: vrednostIliDefault(podesavanja, "firma_pdv_obveznik", "ne"), FirmaFiskalizacija: vrednostIliDefault(podesavanja, "firma_fiskalizacija", "ne"), diff --git a/internal/handler/profil.go b/internal/handler/profil.go index c2415c1..eadca45 100644 --- a/internal/handler/profil.go +++ b/internal/handler/profil.go @@ -289,3 +289,123 @@ func (h *Handler) SacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) { } http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) } + +// ProfilOtpremiAvatar prima upload lične avatar slike korisnika +func (h *Handler) ProfilOtpremiAvatar(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096) + if err := r.ParseMultipartForm(2 << 20); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 2 MB).") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + fajl, zaglavlje, err := r.FormFile("avatar") + 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() + + 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, err := fajl.Read(buf) + if err != nil && err != io.EOF { + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čitanju fajla.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + 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 stari avatar sa diska + svezi, _ := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID) + if svezi != nil && svezi.AvatarPutanja != "" { + deo, _, _ := strings.Cut(svezi.AvatarPutanja, "?") + os.Remove(filepath.Join("web/static/uploads", filepath.Base(deo))) + } + + novoIme := fmt.Sprintf("korisnik_%d_avatar%s", k.ID, ext) + odrediste := filepath.Join("web/static/uploads", novoIme) + dst, err := os.Create(odrediste) + if err != nil { + slog.Error("ProfilOtpremiAvatar: ne mogu kreirati fajl", "error", 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 { + slog.Error("ProfilOtpremiAvatar: greška pri kopiranju", "error", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + // cache-buster verzija u URL-u + v := fmt.Sprintf("%d", time.Now().Unix()) + putanja := fmt.Sprintf("/static/uploads/%s?v=%s", novoIme, v) + + if err := h.KorisniciRepo.SacuvajAvatar(r.Context(), k.ID, putanja); err != nil { + slog.Error("ProfilOtpremiAvatar: greška pri čuvanju u bazi", "error", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je sačuvan.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) +} + +// ProfilUkloniAvatar briše ličnu avatar sliku korisnika +func (h *Handler) ProfilUkloniAvatar(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + svezi, _ := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID) + if svezi != nil && svezi.AvatarPutanja != "" { + deo, _, _ := strings.Cut(svezi.AvatarPutanja, "?") + os.Remove(filepath.Join("web/static/uploads", filepath.Base(deo))) + } + + if err := h.KorisniciRepo.SacuvajAvatar(r.Context(), k.ID, ""); err != nil { + slog.Error("ProfilUkloniAvatar", "error", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju avatara.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + return + } + + middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je uklonjen.") + http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) +} diff --git a/internal/model/izvestaj.go b/internal/model/izvestaj.go index 27f68aa..39f0b77 100644 --- a/internal/model/izvestaj.go +++ b/internal/model/izvestaj.go @@ -15,8 +15,9 @@ type ServisRedDashboard struct { // ZalihaRed je artikal sa kritičnom zalihom (naziv + količina) type ZalihaRed struct { - Naziv string - Kolicina int + Naziv string + Kolicina int + KolicinaMin int } // ProdajaRedDashboard je jedan red za listu poslednjih prodaja na dashboardu diff --git a/internal/model/korisnik.go b/internal/model/korisnik.go index 32b1263..f4b606a 100644 --- a/internal/model/korisnik.go +++ b/internal/model/korisnik.go @@ -18,6 +18,7 @@ type Korisnik struct { LokalnaPozadinaBlur string LokalnaPozadinaBlurPozadine string LokalnaPozadinaGlassOpacity string + AvatarPutanja string } // Sesija predstavlja aktivnu sesiju prijavljenog korisnika diff --git a/internal/model/stranica.go b/internal/model/stranica.go index 3474113..3699fd3 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -36,9 +36,10 @@ type PodaciStranice struct { Tema string NazivFirme string Podnazlov string - LogoTip string // "sa_nazivom", "bez_naziva", "slika" - LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika" - Korisnik string + LogoPutanja string // putanja do slike loga firme + TopbarLogoSlika bool // prikaži logo sliku u topbaru + AvatarPutanja string // putanja do lične avatar slike korisnika + Korisnik string KorisnikIme string // korisničko ime prijavljenog korisnika KorisnikUloga string // uloga: "superadmin", "admin", "radnik" CsrfToken string // CSRF zaštitni token za forme diff --git a/migrations/049_topbar_podesavanja.sql b/migrations/049_topbar_podesavanja.sql new file mode 100644 index 0000000..3c09131 --- /dev/null +++ b/migrations/049_topbar_podesavanja.sql @@ -0,0 +1,4 @@ +-- topbar podešavanja: switch za logo sliku i switch za naziv firme +INSERT OR IGNORE INTO podesavanja (kljuc, vrednost) VALUES + ('topbar_logo_slika', '1'), + ('topbar_logo_tekst', '1'); diff --git a/migrations/050_korisnik_avatar.sql b/migrations/050_korisnik_avatar.sql new file mode 100644 index 0000000..c49dac2 --- /dev/null +++ b/migrations/050_korisnik_avatar.sql @@ -0,0 +1,2 @@ +-- lična avatar slika korisnika (prazno = koristi inicijale) +ALTER TABLE korisnici ADD COLUMN avatar_putanja TEXT NOT NULL DEFAULT ''; diff --git a/web/static/css/main.css b/web/static/css/main.css index 4623bab..2a6d542 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -303,12 +303,28 @@ body { } .topbar-naslov { - font-weight: 500; - font-size: 15px; + font-weight: 600; + font-size: 18px; color: var(--tekst-glavni); flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +/* identifikacija firme u topbaru (naziv + logo) — pomera se i može da se skrati */ +.topbar-firma { flex-shrink: 0; line-height: 1.2; min-width: 0; } +.topbar-firma .topbar-firma-naziv { + font-weight: 700; font-size: 14px; color: var(--tekst-glavni); letter-spacing: -0.2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.topbar-firma .topbar-firma-podnaziv { + font-size: 11px; color: var(--tekst-sporedni); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.topbar-logo { height: 34px; width: auto; border-radius: 6px; flex-shrink: 0; } + /* sadržaj stranice */ .sadrzaj { flex: 1; @@ -583,7 +599,7 @@ body { .avatar-korisnik { width: 32px; height: 32px; - border-radius: 50%; + border-radius: 9px; background: var(--sb-akcent); display: flex; align-items: center; @@ -918,6 +934,11 @@ select { #hamburger-topbar { display: flex !important; color: var(--tekst-glavni); } #hamburger-topbar:hover { background: var(--pozadina); } + /* na telefonu sklanjamo identifikaciju firme iz topbara — naziv i logo su + već vidljivi u bočnom meniju, pa naslov stranice dobija ceo prostor */ + .topbar-firma, .topbar-logo { display: none; } + .topbar-naslov { font-size: 16px; } + /* teme */ .topbar-teme { display: none; } .teme-grid { flex-direction: column !important; } @@ -1017,6 +1038,13 @@ select { .stranica-stack .animiraj:nth-child(4) { animation-delay: 0.28s; } .stranica-stack .animiraj:nth-child(5) { animation-delay: 0.34s; } +/* Dashboard stat kartice — delay da ne krenemo pre nego što view-transition završi */ +.dash-stat.animiraj:nth-child(1) { animation-delay: 0.08s; } +.dash-stat.animiraj:nth-child(2) { animation-delay: 0.13s; } +.dash-stat.animiraj:nth-child(3) { animation-delay: 0.18s; } +.dash-stat.animiraj:nth-child(4) { animation-delay: 0.23s; } +.dash-stat.animiraj:nth-child(5) { animation-delay: 0.28s; } + /* Bedž statusa servisnog naloga — JEDNO mesto za izgled i boje statusa (lista i detalji). Mora biti u main.css: HTMX navigacija odbacuje
, pa page