Compare commits

..

13 Commits

Author SHA1 Message Date
Dasko 1068bb12e0 Bezbednost: limit veličine upload tela, anti-enumeracija pri prijavi, strings.Cut
- CSRF middleware postavlja MaxBytesReader (6 MB) za multipart pre parsiranja —
  pojedinačni upload handleri nisu mogli da ograniče veličinu jer čitanje _csrf
  polja već parsira celo telo
- prijava: dummy bcrypt poređenje kada korisnik ne postoji, da vreme odgovora
  bude isto kao kod postojećeg korisnika (sprečava enumeraciju imena)
- podesavanja: strings.Split(...)[0] zamenjen sa strings.Cut
2026-06-20 14:07:38 +02:00
Dasko 8c0e9d50a0 Formatiranje: poravnanje whitelist mape u podesavanja.go 2026-06-20 14:03:07 +02:00
Dasko 6f0ad3f29c Logo u topbaru: svič ga pokazuje/skriva odmah bez reloda cele strane 2026-06-20 13:57:22 +02:00
Dasko 070f9384cf Logo: otpremanje i greške vode na /admin/podesavanja/opste umesto stare stranice 2026-06-20 13:52:56 +02:00
Dasko fa717208c5 Ispravka: data-full-reload koristiti hasAttribute umesto dataset (prazan atribut je falsy) 2026-06-20 13:45:37 +02:00
Dasko 8855b5b84f Podešavanja Opšte: reload stranice posle čuvanja da se odmah vidi logo i naziv u topbaru 2026-06-20 13:42:15 +02:00
Dasko 45e4863ebb Podešavanja: Sistem, Servis i Kalkulacije ostaju na istoj stranici posle čuvanja 2026-06-20 13:36:31 +02:00
Dasko 8e1cf67618 Tema: pozadina se primenjuje na stranicu tek posle čuvanja, preview ostaje izolovan 2026-06-20 13:30:08 +02:00
Dasko fd35408da7 Tema: slajderi za pozadinu primenjuju se odmah bez refresha
CSS custom properties umesto hardkodovanih vrednosti u <style> bloku;
Alpine  ažurira --app-blur, --app-overlay, --app-glass-* direktno na :root
2026-06-20 13:27:20 +02:00
Dasko 755f56f87a Tema: preview animacije se ponavlja odmah pri promeni brzine slajdera 2026-06-20 13:22:19 +02:00
Dasko 4047f035da Admin: ispravke AJAX toast-a za sve akcije korisnika i dozvola
- base.html: toast pri učitavanju stranice ako URL sadrži ?sacuvano=1 (pokriven i cross-page redirect)
- admin.go: svih 9 SetFlash(uspeh) zamenjeno sa redirect ?sacuvano=1 (korisnici, profil, dozvole)
2026-06-20 13:15:54 +02:00
Dasko 2937acfcc1 Servis: javni status nalog + ispravke AJAX čuvanja
- Dodat javni token na servisni nalog (migracija 057), QR kod vodi na /status/{token}
- Nova javna stranica /status/{token} — bez prijave, za klijente
- Sve forme sa "Sačuvaj izmene" koriste ?sacuvano=1 umesto SetFlash za uspeh
- AJAX logika: toast + ostanak samo kad pathname ostaje isti; inače navigacija
- Ispravke: PDV stope, KIR, KPR, podešavanja izgled, storno prodaje, nivelacija, delovi naloga
2026-06-20 13:04:23 +02:00
Dasko f7a5d2673b Tema: slider za brzinu animacije, zamena scaleIn sa blurIn, AJAX čuvanje
- nova animacija blurIn (zamagljivanje) umesto scaleIn koji je izgledao isto kao fadeIn
- slider za brzinu animacije (0.1s–0.8s, korak 0.1) premešten u karticu animacije
- brzina i vrsta animacije čuvaju se jednim klikom, iz istog forma
- nova kolona lokalna_brzina_animacije u bazi (migracija 056)
- AJAX čuvanje profil/tema: nema reload stranice, scroll ostaje, toast notifikacija
- otpremnica vidljiva samo za status Završeno/Preuzeto; radni nalog skriven kada završeno
- toast notifikacije sa punom bojom pozadine (svetla i tamna tema)
2026-06-20 12:42:11 +02:00
30 changed files with 661 additions and 160 deletions
+2
View File
@@ -239,6 +239,7 @@ func main() {
r.Get("/setup", h.PrikazSetupa)
r.Post("/setup", h.SacuvajSetup)
r.Get("/odjava", h.Odjava)
r.Get("/status/{token}", h.ServisJavniStatus)
// zaštićene rute — zahtevaju prijavljenog korisnika
r.Group(func(r chi.Router) {
@@ -393,6 +394,7 @@ func main() {
r.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu)
r.With(doz("tema.lokalno")).Post("/profil/animacija", h.SacuvajLokalnuAnimaciju)
r.With(doz("tema.lokalno")).Post("/profil/hover", h.SacuvajLokalniHover)
r.With(doz("tema.lokalno")).Post("/profil/brzina-animacije", h.SacuvajLokalnuBrzinuAnimacije)
r.Get("/profil/tema", h.ProfilTema)
r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu)
r.With(doz("tema.lokalno")).Post("/profil/pozadina/ukloni", h.ProfilUkloniPozadinu)
+11
View File
@@ -27,6 +27,17 @@ func ProveriLozinku(hash, lozinka string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(lozinka)) == nil
}
// dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju.
// Koristi ga IzjednaciVremeProvere kada korisnik ne postoji.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost)
// IzjednaciVremeProvere izvršava bcrypt poređenje protiv fiksnog heša da bi vreme
// odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom —
// time se sprečava enumeracija korisničkih imena merenjem vremena odgovora.
func IzjednaciVremeProvere(lozinka string) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(lozinka))
}
// GenerisiToken generiše nasumičan UUID token za sesiju
func GenerisiToken() string {
return uuid.New().String()
+2
View File
@@ -112,6 +112,7 @@ type KlijentRepository interface {
type ServisRepository interface {
Lista(ctx context.Context, pretraga, status string) ([]model.ServisniNalogSaKlijentom, error)
DohvatiID(ctx context.Context, id int64) (*model.ServisniNalog, error)
DohvatiJavniToken(ctx context.Context, token string) (*model.ServisniNalog, error)
Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error)
Izmeni(ctx context.Context, n *model.ServisniNalog) error
AzurirajStatus(ctx context.Context, id int64, status string) error
@@ -156,6 +157,7 @@ type KorisniciRepository interface {
SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error
SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error
SacuvajLokalniHover(ctx context.Context, id int64, hover string) error
SacuvajLokalnuBrzinuAnimacije(ctx context.Context, id int64, brzina string) error
SacuvajAvatar(ctx context.Context, id int64, putanja string) error
PostojiIjedan(ctx context.Context) (bool, error)
Obrisi(ctx context.Context, id int64) error
+23 -5
View File
@@ -32,6 +32,7 @@ type korisnikOpcije struct {
avatarPutanja sql.NullString
lokalnaAnimacija sql.NullString
lokalniHover sql.NullString
lokalnaBrzinaAnimacije sql.NullString
}
// dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik
@@ -48,6 +49,7 @@ func dodeliOpcijeKorisnika(k *model.Korisnik, o korisnikOpcije) {
k.AvatarPutanja = o.avatarPutanja.String
k.LokalnaAnimacija = o.lokalnaAnimacija.String
k.LokalniHover = o.lokalniHover.String
k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String
}
// skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik
@@ -59,7 +61,7 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er
&o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
); err != nil {
return nil, err
}
@@ -104,7 +106,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme
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(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '')
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme)
k, err := skeniraiKorisnika(row)
if err != nil {
@@ -121,7 +124,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model
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(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '')
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
FROM korisnici WHERE id = ?`, id)
k, err := skeniraiKorisnika(row)
if err != nil {
@@ -138,7 +142,8 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
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(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '')
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
FROM korisnici ORDER BY datum_kreiranja ASC`)
if err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
@@ -153,7 +158,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
&o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
); err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
}
@@ -229,6 +234,19 @@ func (r *sqliteKorisniciRepo) SacuvajLokalniHover(ctx context.Context, id int64,
return nil
}
func (r *sqliteKorisniciRepo) SacuvajLokalnuBrzinuAnimacije(ctx context.Context, id int64, brzina string) error {
var val any
if brzina != "" {
val = brzina
}
_, err := r.db.ExecContext(ctx,
`UPDATE korisnici SET lokalna_brzina_animacije = ? WHERE id = ?`, val, id)
if err != nil {
return fmt.Errorf("ntech: korisnici.SacuvajLokalnuBrzinuAnimacije: %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 {
+42 -8
View File
@@ -2,13 +2,24 @@ package sqlite
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"time"
"ntech/internal/model"
)
// generisiJavniToken kreira 32-znakovni hex token za javni URL
func generisiJavniToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// ServisRepo je SQLite implementacija ServisRepository interfejsa
type ServisRepo struct {
db *sql.DB
@@ -43,7 +54,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
sn.opis_kvara, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
sn.avans, sn.napomena, sn.garancija_do, sn.datum_prijema, sn.datum_zavrsetka,
sn.ostecenja, sn.pin_uredjaja, sn.pribor,
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.javni_token,
COALESCE(kp.naziv, '') AS klijent_naziv
FROM servisni_nalozi sn
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
@@ -90,7 +101,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka,
ostecenja, pin_uredjaja, pribor
ostecenja, pin_uredjaja, pribor, javni_token
FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog
@@ -102,21 +113,26 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
return &n, nil
}
// Kreiraj upisuje novi servisni nalog u bazu
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
token, err := generisiJavniToken()
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: token: %w", err)
}
rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO servisni_nalozi
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara,
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka,
ostecenja, pin_uredjaja, pribor, datum_prijema)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
nullString(n.SerijskiBroj), n.OpisKvara, n.Status,
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.DatumPrijema,
n.DatumPrijema, token,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err)
@@ -130,6 +146,23 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
return id, nil
}
// DohvatiJavniToken vraća servisni nalog po javnom tokenu — bez autentifikacije
func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*model.ServisniNalog, error) {
red := r.db.QueryRowContext(ctx, `
SELECT
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka,
ostecenja, pin_uredjaja, pribor, javni_token
FROM servisni_nalozi WHERE javni_token = ?`, token)
var n model.ServisniNalog
if err := scanNalog(red.Scan, &n, nil); err != nil {
return nil, fmt.Errorf("ntech: ServisRepo.DohvatiJavniToken: %w", err)
}
return &n, nil
}
// Izmeni ažurira postojeći servisni nalog — broj_naloga i datum_prijema se ne menjaju
func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
_, err := r.db.ExecContext(ctx, `
@@ -184,7 +217,7 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64) error {
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error {
var klijentID, tehnicarID sql.NullInt64
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor sql.NullString
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, javniToken sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka sql.NullTime
@@ -192,7 +225,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka,
&ostecenja, &pinUredjaja, &pribor,
&ostecenja, &pinUredjaja, &pribor, &javniToken,
}
if klijentNaziv != nil {
@@ -240,6 +273,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := datumZavrsetka.Time
n.DatumZavrsetka = &v
}
n.JavniToken = javniToken.String
return nil
}
+9 -18
View File
@@ -126,8 +126,7 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Korisnik je uspešno kreiran.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminToggleAktivan menja aktivan status korisnika
@@ -172,8 +171,7 @@ func (h *Handler) AdminToggleAktivan(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminPromeniUlogu menja ulogu korisnika
@@ -234,8 +232,7 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminObrisiKorisnika briše korisnika sa ulogom radnik
@@ -279,8 +276,7 @@ func (h *Handler) AdminObrisiKorisnika(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Korisnik je obrisan.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminProfil prikazuje stranicu profila
@@ -406,8 +402,7 @@ func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Lozinka je uspešno promenjena.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
}
// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod
@@ -529,8 +524,7 @@ func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request)
// isključenjem 2FA brišemo i rezervne kodove
_ = h.RezervniKodoviRepo.Obrisi(r.Context(), k.ID)
middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je isključena.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
}
// AdminLoginIstorija prikazuje evidenciju prijava za datog korisnika
@@ -679,8 +673,7 @@ func (h *Handler) AdminDozvolePromeniUlogu(w http.ResponseWriter, r *http.Reques
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
}
// AdminDozvoleSacuvaj prima POST i čuva promene dozvola za radnik i admin uloge
@@ -711,8 +704,7 @@ func (h *Handler) AdminDozvoleSacuvaj(w http.ResponseWriter, r *http.Request) {
}
}
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
}
// AdminDozvoleReset vraća sve dozvole na podrazumevane vrednosti — samo superadmin
@@ -727,6 +719,5 @@ func (h *Handler) AdminDozvoleReset(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Dozvole su vraćene na podrazumevane vrednosti.")
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
}
+1
View File
@@ -215,6 +215,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
if korisnik != nil {
ps.LokalnaAnimacija = korisnik.LokalnaAnimacija
ps.LokalniHover = korisnik.LokalniHover
ps.LokalnaBrzinaAnimacije = korisnik.LokalnaBrzinaAnimacije
}
return ps
+24 -2
View File
@@ -5,6 +5,7 @@ import (
"html/template"
"io/fs"
"log/slog"
"math"
"net/http"
)
@@ -38,13 +39,33 @@ var saSidebar = []string{
// standalone su šabloni bez base layouta
var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica",
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_status_javni",
}
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
// dict gradi mapu iz parova ključ/vrednost — koristi se da se jednom partialu
// prosledi više vrednosti (npr. {{template "x" (dict "ID" .ID "Lista" $.Lista)}}).
var sablonskeFunkcije = template.FuncMap{
// formatBroj formatira float pointer kao ceo broj (zaokružen) — nil vraca ""
"formatBroj": func(v *float64) string {
if v == nil {
return ""
}
return fmt.Sprintf("%d", int64(math.Round(*v)))
},
// statusPre vraća true ako je `a` pre `b` u redosledu statusa
"statusPre": func(a, b string, statusi []string) bool {
ia, ib := -1, -1
for i, s := range statusi {
if s == a {
ia = i
}
if s == b {
ib = i
}
}
return ia >= 0 && ib >= 0 && ia < ib
},
"dict": func(parovi ...any) (map[string]any, error) {
if len(parovi)%2 != 0 {
return nil, fmt.Errorf("dict: neparan broj argumenata")
@@ -77,7 +98,8 @@ func KreirajKes(fsys fs.FS) (map[string]*template.Template, error) {
}
for _, ime := range standaloneIme {
t, err := template.ParseFS(fsys, "web/templates/stranice/"+ime+".html")
// ime+".html" mora biti ime roota da bi Execute() pronašlo sadržaj fajla
t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, "web/templates/stranice/"+ime+".html")
if err != nil {
return nil, fmt.Errorf("kes: %s: %w", ime, err)
}
+3 -2
View File
@@ -93,10 +93,11 @@ func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) {
switch {
case errors.Is(err, sqlite.ErrArtikalNePostoji):
middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
case err != nil:
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri promeni cene.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
default:
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajna cena je izmenjena.")
http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther)
}
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
}
+2 -4
View File
@@ -169,8 +169,7 @@ func (h *Handler) SacuvajPdvKir(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Izlazni račun je dodat u KIR.")
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kir?sacuvano=1", http.StatusSeeOther)
}
// ObrisiPdvKir briše zapis iz KIR
@@ -187,6 +186,5 @@ func (h *Handler) ObrisiPdvKir(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KIR.")
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kir?obrisan=1", http.StatusSeeOther)
}
+2 -4
View File
@@ -154,8 +154,7 @@ func (h *Handler) SacuvajPdvKpr(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Ulazni račun je dodat u KPR.")
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kpr?sacuvano=1", http.StatusSeeOther)
}
// ObrisiPdvKpr briše zapis iz KPR
@@ -172,6 +171,5 @@ func (h *Handler) ObrisiPdvKpr(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KPR.")
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kpr?obrisan=1", http.StatusSeeOther)
}
+3 -10
View File
@@ -99,8 +99,7 @@ func (h *Handler) DodajPdvStopu(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju PDV stope", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je dodata.")
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
// IzmeniPdvStopu prima POST i menja postojeću stopu
@@ -128,8 +127,7 @@ func (h *Handler) IzmeniPdvStopu(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri izmeni PDV stope", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je izmenjena.")
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
// PromeniAktivnostPdvStope arhivira ili vraća stopu u upotrebu (toggle, bez brisanja)
@@ -151,10 +149,5 @@ func (h *Handler) PromeniAktivnostPdvStope(w http.ResponseWriter, r *http.Reques
http.Error(w, "Greška pri promeni statusa PDV stope", http.StatusInternalServerError)
return
}
poruka := "PDV stopa je arhivirana."
if !postojeca.Aktivna {
poruka = "PDV stopa je vraćena u upotrebu."
}
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
+26 -25
View File
@@ -23,13 +23,13 @@ import (
// PodaciPodesavanja su podaci za stranicu podešavanja
type PodaciPodesavanja struct {
model.PodaciStranice
NazivFirme string
Podnazlov string
Adresa string
Telefon string
PIB string
LogoPutanja string
TopbarLogoSlika bool
NazivFirme string
Podnazlov string
Adresa string
Telefon string
PIB string
LogoPutanja string
TopbarLogoSlika bool
// profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale
FirmaPravniOblik string
FirmaPdvObveznik string
@@ -266,8 +266,11 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
// whitelist dozvoljenih redirekcija — vrednost uvek dolazi iz mape, nikad od korisnika
dozvoljeniSledeci := map[string]string{
"/admin/podesavanja/opste": "/admin/podesavanja/opste",
"/podesavanja": "/podesavanja",
"/admin/podesavanja/opste": "/admin/podesavanja/opste",
"/admin/podesavanja/sistem": "/admin/podesavanja/sistem",
"/admin/podesavanja/servis": "/admin/podesavanja/servis",
"/admin/podesavanja/kalkulacija-pdv": "/admin/podesavanja/kalkulacija-pdv",
"/podesavanja": "/podesavanja",
}
sledeci := "/podesavanja"
if v, ok := dozvoljeniSledeci[r.FormValue("_next")]; ok {
@@ -359,20 +362,20 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
// ograničavamo telo zahteva na 2MB + malo za zaglavlja forme
r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096)
if err := r.ParseMultipartForm(2 << 20); err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
return
}
fajl, zaglavlje, err := r.FormFile("logo")
if err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Nije+odabran+fajl", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Nije+odabran+fajl", http.StatusSeeOther)
return
}
defer fajl.Close()
// eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera)
if zaglavlje.Size > 2<<20 {
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
return
}
@@ -386,7 +389,7 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
}
ocekivaniMime, ok := dozvoljenoExt[ext]
if !ok {
http.Redirect(w, r, "/podesavanja?logo_greska=Dozvoljeni+formati+su+PNG%2C+JPG+i+SVG", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Dozvoljeni+formati+su+PNG%2C+JPG+i+SVG", http.StatusSeeOther)
return
}
@@ -396,12 +399,12 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
n, _ := fajl.Read(buf)
stvarniMime := http.DetectContentType(buf[:n])
if !strings.HasPrefix(stvarniMime, ocekivaniMime) {
http.Redirect(w, r, "/podesavanja?logo_greska=Sadržaj+fajla+ne+odgovara+odabranoj+ekstenziji", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Sadržaj+fajla+ne+odgovara+odabranoj+ekstenziji", http.StatusSeeOther)
return
}
// vraćamo kursor na početak
if _, err := fajl.Seek(0, io.SeekStart); err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+obradi+fajla", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+obradi+fajla", http.StatusSeeOther)
return
}
}
@@ -416,14 +419,14 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
dst, err := os.Create(odrediste)
if err != nil {
slog.Error("upload loga: ne mogu kreirati fajl", "error", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
return
}
defer dst.Close()
if _, err := io.Copy(dst, fajl); err != nil {
slog.Error("upload loga: greška pri kopiranju", "error", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
return
}
@@ -431,11 +434,11 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
putanja := fmt.Sprintf("/static/uploads/logo%s?v=%d", ext, time.Now().Unix())
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
slog.Error("upload loga: greška pri čuvanju putanje", "error", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther)
}
// UkloniLogo briše logo fajl i čisti putanju iz podešavanja
@@ -536,7 +539,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) {
staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if stara := staraPodesavanja["login_pozadina"]; stara != "" {
// putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla
deoBezverzije := strings.Split(stara, "?")[0]
deoBezverzije, _, _ := strings.Cut(stara, "?")
staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme))
}
@@ -587,7 +590,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err == nil {
if stara := podesavanja["login_pozadina"]; stara != "" {
deoBezverzije := strings.Split(stara, "?")[0]
deoBezverzije, _, _ := strings.Cut(stara, "?")
staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme))
}
@@ -600,8 +603,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.")
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
}
// SacuvajLoginPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine login stranice
@@ -659,8 +661,7 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req
}
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Izgled pozadine prijave je sačuvan.")
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
}
// napuniPodaciPodesavanja učitava sva podešavanja i kreira strukturu za template
+2
View File
@@ -98,6 +98,8 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme)
if err != nil {
// izjednačavamo vreme odgovora sa slučajem postojećeg korisnika (anti-enumeracija)
auth.IzjednaciVremeProvere(lozinka)
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false)
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "korisnik_ne_postoji", false)
auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_user")
+1 -2
View File
@@ -432,8 +432,7 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
+67 -35
View File
@@ -15,6 +15,22 @@ import (
"ntech/internal/middleware"
)
// sacuvanoReferer vraća URL za redirect posle uspešnog čuvanja:
// koristi Referer zaglavlje (samo putanja, nikad spoljni sajt) i dodaje ?sacuvano=1.
// AJAX handler u browseru detektuje sacuvano=1 i prikazuje toast bez reloada.
func sacuvanoReferer(r *http.Request, rezervna string) string {
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil {
putanja := u.RequestURI()
if strings.Contains(putanja, "?") {
return putanja + "&sacuvano=1"
}
return putanja + "?sacuvano=1"
}
}
return rezervna + "?sacuvano=1"
}
// ProfilTema prikazuje stranicu lične teme i pozadine
func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
@@ -177,8 +193,7 @@ func (h *Handler) ProfilOtpremiPozadinu(w http.ResponseWriter, r *http.Request)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
// ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika
@@ -202,8 +217,7 @@ func (h *Handler) ProfilUkloniPozadinu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
// ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine
@@ -250,8 +264,7 @@ func (h *Handler) ProfilSacuvajPozadinuStilove(w http.ResponseWriter, r *http.Re
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Podešavanja su sačuvana.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
// SacuvajLokalnuTemu čuva korisnikovu lokalnu temu
@@ -284,15 +297,7 @@ func (h *Handler) SacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Tema je sačuvana.")
// koristimo samo putanju iz Referer zaglavlja — nikad ceo URL jer može biti spoljni sajt
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
return
}
}
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil"), http.StatusSeeOther)
}
// SacuvajLokalnuAnimaciju čuva korisnikovu preferencu animacije tabela
@@ -313,25 +318,29 @@ func (h *Handler) SacuvajLokalnuAnimaciju(w http.ResponseWriter, r *http.Request
}
animacija := r.FormValue("lokalna_animacija")
dozvoljene := map[string]bool{"": true, "bez": true, "fadeInUp": true, "fadeIn": true, "scaleIn": true, "slideLeft": true}
dozvoljene := map[string]bool{"": true, "bez": true, "fadeInUp": true, "fadeIn": true, "blurIn": true, "slideLeft": true}
if !dozvoljene[animacija] {
animacija = ""
}
brzina := r.FormValue("lokalna_brzina_animacije")
dozvoljenieBrzine := map[string]bool{"": true, "0.1": true, "0.2": true, "0.3": true, "0.4": true, "0.5": true, "0.6": true, "0.7": true, "0.8": true}
if !dozvoljenieBrzine[brzina] {
brzina = ""
}
if err := h.KorisniciRepo.SacuvajLokalnuAnimaciju(r.Context(), k.ID, animacija); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Animacija je sačuvana.")
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
return
}
if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
}
// SacuvajLokalniHover čuva korisnikovu preferencu hover efekta na karticama
@@ -363,14 +372,39 @@ func (h *Handler) SacuvajLokalniHover(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Hover efekat je sačuvan.")
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
return
}
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
}
// SacuvajLokalnuBrzinuAnimacije čuva korisnikovu preferencu brzine animacije tabela
func (h *Handler) SacuvajLokalnuBrzinuAnimacije(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
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/tema", http.StatusSeeOther)
return
}
brzina := r.FormValue("lokalna_brzina_animacije")
dozvoljene := map[string]bool{"": true, "0.2": true, "0.4": true, "0.6": true, "0.8": true, "1.0": true, "1.2": true, "1.5": true}
if !dozvoljene[brzina] {
brzina = ""
}
if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return
}
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
}
// ProfilOtpremiAvatar prima upload lične avatar slike korisnika
@@ -464,8 +498,7 @@ func (h *Handler) ProfilOtpremiAvatar(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je sačuvan.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
// ProfilUkloniAvatar briše ličnu avatar sliku korisnika
@@ -489,6 +522,5 @@ func (h *Handler) ProfilUkloniAvatar(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je uklonjen.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
+38 -8
View File
@@ -444,8 +444,7 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je dodat.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga
@@ -470,10 +469,7 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
slog.Error("greška pri brisanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.")
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je uklonjen.")
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
}
@@ -647,12 +643,12 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
ukupnoDelovi += d.Ukupno()
}
// QR kod sadrži URL naloga — isti host kao što korisnik koristi
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
nalogURL := scheme + "://" + r.Host + "/servis/" + strconv.FormatInt(id, 10)
nalogURL := scheme + "://" + r.Host + "/status/" + nalog.JavniToken
var qrKod string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKod = base64.StdEncoding.EncodeToString(png)
@@ -762,7 +758,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
nalogURL += "s"
}
nalogURL += "://" + r.Host + "/servis/" + strconv.FormatInt(id, 10)
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
var qrKodOtpr string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKodOtpr = base64.StdEncoding.EncodeToString(png)
@@ -813,3 +809,37 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
type PodaciJavnogStatusa struct {
Nalog model.ServisniNalog
NazivFirme string
Telefon string
Adresa string
SviStatusi []string
}
// ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda
func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
http.NotFound(w, r)
return
}
nalog, err := h.ServisRepo.DohvatiJavniToken(r.Context(), token)
if err != nil {
http.NotFound(w, r)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
Nalog: *nalog,
NazivFirme: podesavanja["naziv_firme"],
Telefon: podesavanja["telefon"],
Adresa: podesavanja["adresa"],
SviStatusi: model.SviStatusi,
})
}
+12
View File
@@ -6,10 +6,17 @@ import (
"encoding/base64"
"net/http"
"os"
"strings"
)
const csrfKolacic = "ntech_csrf"
// maxTeloUpload je gornja granica veličine tela multipart zahteva (upload fajlova).
// Postavlja se u middleware-u pre parsiranja jer čitanje _csrf polja već parsira
// telo — bez ovog limita pojedinačni handleri ne mogu da ga efektivno ograniče.
// Najveći legitiman upload je 5 MB (avatar, pozadina); ostatak je rezerva za form overhead.
const maxTeloUpload = 6 << 20
type csrfKljucTip struct{}
var csrfKljuc = csrfKljucTip{}
@@ -52,6 +59,11 @@ func CsrfMiddleware(next http.Handler) http.Handler {
// validiramo na svim mutabilnim HTTP metodama
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
// ograničavamo veličinu tela PRE parsiranja — čitanje _csrf polja parsira
// celo telo, pa je ovo jedino mesto gde limit za upload stvarno deluje
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
r.Body = http.MaxBytesReader(w, r.Body, maxTeloUpload)
}
// čitamo token iz tela forme ili zaglavlja (za AJAX)
submitted := r.FormValue("_csrf")
if submitted == "" {
+1
View File
@@ -21,6 +21,7 @@ type Korisnik struct {
AvatarPutanja string
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | "0.6" | "0.8" | "1.2" (sekunde)
}
// Sesija predstavlja aktivnu sesiju prijavljenog korisnika
+1
View File
@@ -46,6 +46,7 @@ type ServisniNalog struct {
Ostecenja string
PinUredjaja string
Pribor string
JavniToken string
}
// ServisniDeo predstavlja jedan artikal ugrađen u servisni nalog
+1
View File
@@ -55,6 +55,7 @@ type PodaciStranice struct {
AppPozadinaGlassOpacity string // vrednost 0-80 (% zatamnjivanje glass elemenata) — samo za ličnu pozadinu
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | ... | "1.5" (sekunde)
}
// PodaciDashboarda su podaci specifični za dashboard stranicu
+1
View File
@@ -0,0 +1 @@
ALTER TABLE korisnici ADD COLUMN lokalna_brzina_animacije TEXT NOT NULL DEFAULT '';
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE servisni_nalozi ADD COLUMN javni_token TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_servisni_nalozi_javni_token ON servisni_nalozi(javni_token);
+27 -15
View File
@@ -5,6 +5,16 @@
padding: 0;
}
:root { --anim-trajanje: 0.4s; }
[data-brzina-animacije="0.1"] { --anim-trajanje: 0.1s; }
[data-brzina-animacije="0.2"] { --anim-trajanje: 0.2s; }
[data-brzina-animacije="0.3"] { --anim-trajanje: 0.3s; }
[data-brzina-animacije="0.4"] { --anim-trajanje: 0.4s; }
[data-brzina-animacije="0.5"] { --anim-trajanje: 0.5s; }
[data-brzina-animacije="0.6"] { --anim-trajanje: 0.6s; }
[data-brzina-animacije="0.7"] { --anim-trajanje: 0.7s; }
[data-brzina-animacije="0.8"] { --anim-trajanje: 0.8s; }
html {
background: var(--pozadina);
}
@@ -324,6 +334,8 @@ body {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.topbar-logo { height: 34px; width: auto; border-radius: 6px; flex-shrink: 0; }
/* sakrivanje preko klase (ne inline display) da mobilni @media koji gasi logo ostane nadređen */
.topbar-logo.skriven { display: none; }
/* sadržaj stranice */
.sadrzaj {
@@ -793,8 +805,8 @@ select {
animation: toastIn 0.3s ease forwards;
max-width: 340px;
}
.toast-greska { background: rgba(207, 87, 87, 0.12); color: var(--greska); border: 0.5px solid var(--greska); }
.toast-uspeh { background: var(--poruka-uspeh-bg); color: var(--poruka-uspeh-boja); border: 0.5px solid var(--poruka-uspeh-boja); }
.toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; }
.toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; }
.toast.nestaje { animation: toastOut 0.3s ease forwards; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(12px); }
@@ -1011,7 +1023,7 @@ select {
/* animacije */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -1020,13 +1032,13 @@ select {
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
@keyframes blurIn {
from { opacity: 0; filter: blur(8px); }
to { opacity: 1; filter: blur(0); }
}
@keyframes slideLeft {
from { opacity: 0; transform: translateX(-12px); }
from { opacity: 0; transform: translateX(-18px); }
to { opacity: 1; transform: translateX(0); }
}
@@ -1050,7 +1062,7 @@ select {
/* backwards (ne both): drži početni frame tokom stagger-delay-a da nema treperenja,
ali NE zaključava krajnji transform pa .kartica:hover lift radi i na .animiraj karticama */
.animiraj {
animation: fadeInUp 0.2s ease backwards;
animation: fadeInUp var(--anim-trajanje) ease backwards;
}
/* kartice ne animiramo po defaultu — animacija je samo na redovima tabela */
@@ -1066,26 +1078,26 @@ select {
[data-animacija="fadeInUp"] .animiraj,
[data-animacija="fadeInUp"] .tabela tbody tr,
[data-animacija="fadeInUp"] .kartica.animiraj { animation: fadeInUp 0.2s ease backwards; }
[data-animacija="fadeInUp"] .kartica.animiraj { animation: fadeInUp var(--anim-trajanje) ease backwards; }
[data-animacija="fadeIn"] .animiraj,
[data-animacija="fadeIn"] .tabela tbody tr,
[data-animacija="fadeIn"] .kartica.animiraj { animation: fadeIn 0.2s ease backwards; }
[data-animacija="fadeIn"] .kartica.animiraj { animation: fadeIn var(--anim-trajanje) ease backwards; }
[data-animacija="scaleIn"] .animiraj,
[data-animacija="scaleIn"] .tabela tbody tr,
[data-animacija="scaleIn"] .kartica.animiraj { animation: scaleIn 0.2s ease backwards; }
[data-animacija="blurIn"] .animiraj,
[data-animacija="blurIn"] .tabela tbody tr,
[data-animacija="blurIn"] .kartica.animiraj { animation: blurIn var(--anim-trajanje) ease backwards; }
[data-animacija="slideLeft"] .animiraj,
[data-animacija="slideLeft"] .tabela tbody tr,
[data-animacija="slideLeft"] .kartica.animiraj { animation: slideLeft 0.2s ease backwards; }
[data-animacija="slideLeft"] .kartica.animiraj { animation: slideLeft var(--anim-trajanje) ease backwards; }
/* Stepenasta (stagger) animacija redova u svim listama JEDNO mesto za sve tabele.
Animacija se primenjuje direktno na <tr> u .tabela, bez potrebe za .animiraj klasom.
Mora biti u main.css: HTMX navigacija (hx-select) odbacuje <head>, pa
stilovi iz page <style> blokova ne bi važili tokom navigacije. */
.tabela tbody tr {
animation: fadeInUp 0.2s ease backwards;
animation: fadeInUp var(--anim-trajanje) ease backwards;
}
.tabela tbody tr:nth-child(1) { animation-delay: 0.04s; }
.tabela tbody tr:nth-child(2) { animation-delay: 0.08s; }
+3
View File
@@ -22,3 +22,6 @@
--poruka-uspeh-bg: rgba(93, 184, 118, 0.15);
--poruka-uspeh-boja: #5db876;
}
.toast-uspeh { background: #1a2e22; color: #5db876; border: 0.5px solid #2d5a3d; }
.toast-greska { background: #2e1a1a; color: #e07070; border: 0.5px solid #5a2d2d; }
+2 -2
View File
@@ -10,8 +10,8 @@
</svg>
</button>
{{if and .TopbarLogoSlika .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" class="topbar-logo">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" class="topbar-logo{{if not .TopbarLogoSlika}} skriven{{end}}">
{{end}}
<span class="topbar-naslov">{{.NaslovStranice}}</span>
+38 -10
View File
@@ -83,24 +83,23 @@
<form method="POST" action="/profil/animacija">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="lokalna_brzina_animacije" id="brzina-hidden" value="{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}{{else}}0.4{{end}}">
<div class="kolona" style="gap:16px;">
<div style="max-width:300px;">
<label class="polje-labela" for="anim-select">Vrsta animacije pri učitavanju</label>
<select id="anim-select" name="lokalna_animacija" style="width:100%;" onchange="animPreview(this.value)"><!-- preview se primenjuje na wrapper, ne na body -->
<select id="anim-select" name="lokalna_animacija" style="width:100%;" onchange="animPreview(this.value)">
<option value="" {{if eq .LokalnaAnimacija ""}}selected{{end}}>Podrazumevano (klizanje gore)</option>
<option value="bez" {{if eq .LokalnaAnimacija "bez"}}selected{{end}}>Bez animacije</option>
<option value="fadeInUp" {{if eq .LokalnaAnimacija "fadeInUp"}}selected{{end}}>Klizanje gore (fadeInUp)</option>
<option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje (fadeIn)</option>
<option value="scaleIn" {{if eq .LokalnaAnimacija "scaleIn"}}selected{{end}}>Zumiranje (scaleIn)</option>
<option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva (slideLeft)</option>
<option value="fadeInUp" {{if eq .LokalnaAnimacija "fadeInUp"}}selected{{end}}>Klizanje gore</option>
<option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje</option>
<option value="blurIn" {{if eq .LokalnaAnimacija "blurIn"}}selected{{end}}>Zamagljivanje</option>
<option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva</option>
</select>
</div>
<!-- preview -->
<div>
<div class="pomocni-tekst" style="margin-bottom:8px;">Pregled:</div>
<!-- preview wrapper nosi data-animacija atribut; isti CSS [data-animacija] .animiraj
koji radi globalno na body radi i ovde, ali izolovano — pogađa SAMO redove unutar wrappera -->
<div id="anim-preview-wrap" style="border:0.5px solid var(--ivica);border-radius:8px;overflow:hidden;max-width:340px;">
<table style="width:100%;border-collapse:collapse;">
<tbody id="anim-preview-body">
@@ -116,6 +115,20 @@
</button>
</div>
<!-- slider brzine unutar iste forme -->
<div style="max-width:340px;">
<label class="polje-labela" for="brzina-slider">Trajanje efekta: <span id="brzina-labela">{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}s{{else}}0.4s{{end}}</span></label>
<input type="range" id="brzina-slider"
min="0.1" max="0.8" step="0.1"
value="{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}{{else}}0.4{{end}}"
style="width:100%;margin-top:8px;accent-color:var(--sb-akcent);"
oninput="brzinaPromena(this.value)">
<div style="display:flex;justify-content:space-between;margin-top:4px;">
<span style="font-size:11px;color:var(--tekst-sporedni);">0.1s (brže)</span>
<span style="font-size:11px;color:var(--tekst-sporedni);">0.8s (sporije)</span>
</div>
</div>
<div>
<button type="submit" class="btn-primarno">Sačuvaj</button>
</div>
@@ -376,7 +389,7 @@ var animVrednosti = {
'bez': 'bez',
'fadeInUp': 'fadeInUp',
'fadeIn': 'fadeIn',
'scaleIn': 'scaleIn',
'blurIn': 'blurIn',
'slideLeft': 'slideLeft'
};
// postavlja data-animacija na PREVIEW wrapper (ne na body), pa CSS
@@ -385,10 +398,13 @@ function animPreview(val) {
var wrap = document.getElementById('anim-preview-wrap');
if (!wrap) return;
var anim = animVrednosti[val] || 'fadeInUp';
// restartuj animaciju: skini atribut, izazovi reflow, pa ga vrati
// skini data-animacija i klasu animiraj sa redova da bi se animacija resetovala
wrap.removeAttribute('data-animacija');
void wrap.offsetHeight; // reflow da bi se animacija ponovo okinula
var redovi = wrap.querySelectorAll('.animiraj');
redovi.forEach(function(r) { r.classList.remove('animiraj'); });
void wrap.offsetHeight; // reflow — bez ovoga browser spoji promene i ne restartuje
wrap.setAttribute('data-animacija', anim);
redovi.forEach(function(r) { r.classList.add('animiraj'); });
}
// "Ponovi pregled" — ponovo okida animaciju za trenutno izabrani stil
function animPreviewPonovi() {
@@ -399,5 +415,17 @@ function animPreviewPonovi() {
var sel = document.getElementById('anim-select');
if (sel) animPreview(sel.value);
})();
function brzinaPromena(val) {
var v = parseFloat(val).toFixed(1);
document.getElementById('brzina-hidden').value = v;
document.getElementById('brzina-labela').textContent = v + 's';
document.body.dataset.brzinaAnimacije = v;
var wrap = document.getElementById('anim-preview-wrap');
if (wrap) wrap.style.setProperty('--anim-trajanje', v + 's');
// ponovi preview da korisnik odmah vidi novu brzinu
var sel = document.getElementById('anim-select');
if (sel) animPreview(sel.value);
}
</script>
{{end}}
@@ -57,12 +57,16 @@
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
{{if not (or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto"))}}
<a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno">
Radni nalog
</a>
{{end}}
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
Otpremnica
</a>
{{end}}
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno">
Izmeni nalog
</a>
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Status popravke — {{.Nalog.BrojNaloga}}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 14px; color: #111; background: #f5f5f7; min-height: 100vh; }
.omotac { max-width: 480px; margin: 0 auto; padding: 24px 16px 48px; }
/* zaglavlje firme */
.zaglavlje { text-align: center; margin-bottom: 24px; }
.firma-naziv { font-size: 18px; font-weight: 700; color: #111; }
.firma-info { font-size: 12px; color: #666; margin-top: 4px; line-height: 1.6; }
/* kartica naloga */
.kartica { background: #fff; border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.kartica-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #999; margin-bottom: 12px; }
.uredjaj { font-size: 22px; font-weight: 700; color: #111; line-height: 1.2; }
.broj-naloga { font-size: 13px; color: #888; margin-top: 4px; font-family: monospace; }
/* status bedž */
.status-bedz {
display: inline-block;
padding: 6px 14px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
margin-top: 14px;
}
.status-primljeno { background: #e8f0fe; color: #1a56db; }
.status-dijagnostika { background: #fef3c7; color: #b45309; }
.status-ceka { background: #fee2e2; color: #dc2626; }
.status-popravka { background: #fef3c7; color: #b45309; }
.status-zavrseno { background: #d1fae5; color: #059669; }
.status-preuzeto { background: #f3f4f6; color: #374151; }
/* napredak */
.napredak { margin: 16px 0 4px; }
.napredak-labela { font-size: 11px; color: #999; margin-bottom: 8px; }
.koraci { display: flex; gap: 4px; }
.korak {
flex: 1;
height: 5px;
border-radius: 3px;
background: #e5e7eb;
}
.korak.aktivan { background: #10b981; }
.korak.zavrseno { background: #10b981; }
.korak.tekuci { background: #f59e0b; }
/* red podataka */
.red { display: flex; justify-content: space-between; align-items: flex-start; padding: 10px 0; border-bottom: 0.5px solid #f0f0f0; gap: 12px; }
.red:last-child { border-bottom: none; }
.red-labela { font-size: 12px; color: #888; flex-shrink: 0; }
.red-vrednost { font-size: 13px; color: #111; font-weight: 500; text-align: right; }
/* cena */
.cena-blok { background: #f9fafb; border-radius: 10px; padding: 14px 16px; margin-top: 4px; }
.cena-od-do { display: flex; gap: 8px; align-items: center; }
.cena-od-do .cena-vrednost { font-size: 20px; font-weight: 700; color: #111; }
.cena-od-do .cena-sep { color: #bbb; }
.cena-labela { font-size: 11px; color: #888; margin-top: 2px; }
/* kontakt */
.kontakt { background: #fff; border-radius: 16px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.kontakt-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #999; margin-bottom: 12px; }
.kontakt-tel { font-size: 18px; font-weight: 700; color: #1a56db; text-decoration: none; display: block; }
.kontakt-adresa { font-size: 13px; color: #666; margin-top: 6px; line-height: 1.5; }
/* napomena za klijenta */
.napomena-blok { background: #fffbeb; border-radius: 10px; padding: 12px 14px; margin-top: 4px; font-size: 13px; color: #555; line-height: 1.5; }
</style>
</head>
<body>
<div class="omotac">
<!-- zaglavlje firme -->
<div class="zaglavlje">
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}Servis{{end}}</div>
{{if .Adresa}}<div class="firma-info">{{.Adresa}}</div>{{end}}
</div>
<!-- kartica: uređaj i status -->
<div class="kartica">
<div class="kartica-naslov">Vaš uređaj</div>
<div class="uredjaj">{{.Nalog.Uredjaj}}</div>
<div class="broj-naloga">Nalog: {{.Nalog.BrojNaloga}}</div>
{{$s := .Nalog.Status}}
{{if eq $s "Primljeno"}}
<span class="status-bedz status-primljeno">Primljeno</span>
{{else if eq $s "U dijagnostici"}}
<span class="status-bedz status-dijagnostika">U dijagnostici</span>
{{else if eq $s "Čeka delove"}}
<span class="status-bedz status-ceka">Čeka delove</span>
{{else if eq $s "U popravci"}}
<span class="status-bedz status-popravka">U popravci</span>
{{else if eq $s "Završeno"}}
<span class="status-bedz status-zavrseno">Završeno — možete preuzeti</span>
{{else if eq $s "Preuzeto"}}
<span class="status-bedz status-preuzeto">Preuzeto</span>
{{end}}
<!-- traka napretka -->
<div class="napredak">
<div class="napredak-labela">Napredak popravke</div>
<div class="koraci">
{{range $i, $korak := .SviStatusi}}
{{if eq $korak $s}}
<div class="korak tekuci"></div>
{{else if statusPre $korak $s $.SviStatusi}}
<div class="korak zavrseno"></div>
{{else}}
<div class="korak"></div>
{{end}}
{{end}}
</div>
</div>
</div>
<!-- kartica: detalji -->
<div class="kartica">
<div class="kartica-naslov">Detalji</div>
<div class="red">
<span class="red-labela">Datum prijema</span>
<span class="red-vrednost">{{.Nalog.DatumPrijema.Format "02.01.2006."}}</span>
</div>
{{if .Nalog.DatumZavrsetka}}
<div class="red">
<span class="red-labela">Datum završetka</span>
<span class="red-vrednost">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</span>
</div>
{{end}}
{{if .Nalog.SerijskiBroj}}
<div class="red">
<span class="red-labela">Serijski broj</span>
<span class="red-vrednost">{{.Nalog.SerijskiBroj}}</span>
</div>
{{end}}
{{if .Nalog.OpisKvara}}
<div class="red">
<span class="red-labela">Opis kvara</span>
<span class="red-vrednost">{{.Nalog.OpisKvara}}</span>
</div>
{{end}}
{{if .Nalog.GarancijaDo}}
<div class="red">
<span class="red-labela">Garancija do</span>
<span class="red-vrednost">{{.Nalog.GarancijaDo.Format "02.01.2006."}}</span>
</div>
{{end}}
<!-- procenjena cena -->
{{if or .Nalog.CenaOd .Nalog.CenaDo .Nalog.CenaKonacna}}
<div style="margin-top:12px;">
<div class="red-labela" style="margin-bottom:8px;">Procena cene</div>
<div class="cena-blok">
{{if .Nalog.CenaKonacna}}
<div class="cena-od-do">
<span class="cena-vrednost">{{formatBroj .Nalog.CenaKonacna}} din</span>
</div>
<div class="cena-labela">Konačna cena popravke</div>
{{else if and .Nalog.CenaOd .Nalog.CenaDo}}
<div class="cena-od-do">
<span class="cena-vrednost">{{formatBroj .Nalog.CenaOd}}</span>
<span class="cena-sep"></span>
<span class="cena-vrednost">{{formatBroj .Nalog.CenaDo}} din</span>
</div>
<div class="cena-labela">Procenjeni raspon cene</div>
{{else if .Nalog.CenaOd}}
<div class="cena-od-do">
<span class="cena-vrednost">od {{formatBroj .Nalog.CenaOd}} din</span>
</div>
<div class="cena-labela">Procenjena cena od</div>
{{end}}
</div>
</div>
{{end}}
{{if .Nalog.Napomena}}
<div style="margin-top:12px;">
<div class="red-labela" style="margin-bottom:6px;">Napomena</div>
<div class="napomena-blok">{{.Nalog.Napomena}}</div>
</div>
{{end}}
</div>
<!-- kontakt -->
{{if .Telefon}}
<div class="kontakt">
<div class="kontakt-naslov">Kontakt</div>
<a href="tel:{{.Telefon}}" class="kontakt-tel">{{.Telefon}}</a>
{{if .Adresa}}<div class="kontakt-adresa">{{.Adresa}}</div>{{end}}
</div>
{{end}}
</div>
</body>
</html>
+103 -10
View File
@@ -28,6 +28,14 @@
{{if .AppPozadina}}
<style>
:root {
--app-blur-bg: {{.AppPozadinaBlurPozadine}}px;
--app-blur-bg-inset: {{if ne .AppPozadinaBlurPozadine "0"}}-20px{{else}}0{{end}};
--app-overlay: {{.AppPozadinaOpacity}}%;
--app-blur: {{.AppPozadinaBlur}}px;
--app-glass-sb: {{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}30%{{end}};
--app-glass-el: {{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}8%{{end}};
}
html {
background: url('{{.AppPozadina}}') center/cover fixed;
background-color: #1f2228;
@@ -36,9 +44,9 @@
body::before {
content: '';
position: fixed;
inset: {{if ne .AppPozadinaBlurPozadine "0"}}-20px{{else}}0{{end}};
inset: var(--app-blur-bg-inset);
background: url('{{.AppPozadina}}') center/cover;
filter: blur({{.AppPozadinaBlurPozadine}}px);
filter: blur(var(--app-blur-bg));
z-index: 0;
pointer-events: none;
}
@@ -46,18 +54,18 @@
content: '';
position: fixed;
inset: 0;
background: rgba(0,0,0,{{.AppPozadinaOpacity}}%);
background: rgba(0,0,0,var(--app-overlay));
z-index: 1;
pointer-events: none;
}
.raspored { position: relative; z-index: 2; }
.sidebar { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.3{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border-right: 1px solid rgba(255,255,255,0.12) !important; }
.sidebar { background: rgba(0,0,0,var(--app-glass-sb)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border-right: 1px solid rgba(255,255,255,0.12) !important; }
.sidebar .nav-stavka, .sidebar .logo-naziv, .sidebar .logo-podnazlov { text-shadow: 0 1px 3px rgba(0,0,0,0.8); color: rgba(255,255,255,0.95) !important; }
.sidebar .nav-stavka svg { color: rgba(255,255,255,0.95) !important; stroke: rgba(255,255,255,0.95) !important; }
.sidebar .nav-oznaka { text-shadow: 0 1px 3px rgba(0,0,0,0.8); color: rgba(255,255,255,0.7) !important; }
.topbar { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.08{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border-bottom: 1px solid rgba(255,255,255,0.12) !important; }
.topbar { background: rgba(0,0,0,var(--app-glass-el)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border-bottom: 1px solid rgba(255,255,255,0.12) !important; }
.topbar-naslov { color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 2px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.55); }
.kartica, .premesti-modal { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.08{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border: 1px solid rgba(255,255,255,0.12) !important; }
.kartica, .premesti-modal { background: rgba(0,0,0,var(--app-glass-el)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border: 1px solid rgba(255,255,255,0.12) !important; }
body:not([data-hover]) .kartica:hover { background: rgba(0,0,0,0.38) !important; }
body:not([data-hover]) .kartica:hover .dash-ikona { filter: brightness(0.55); }
.kartica p, .kartica span, .kartica h1, .kartica h2, .kartica h3, .kartica h4, .kartica label, .kartica td, .kartica th, .kartica li, .kartica a { color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 2px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.55); }
@@ -75,7 +83,7 @@
dobijaju sopstvenu staklenu podlogu da se vide bez obzira na sliku ispod */
.nazad-link, .btn-sekundarno, .btn-obrisi-ghost, .cek-filter {
background: rgba(0,0,0,0.28) !important;
backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px);
backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur));
border: 1px solid rgba(255,255,255,0.18) !important;
color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
@@ -110,7 +118,7 @@
</script>
{{end}}
</head>
<body{{if .LokalnaAnimacija}} data-animacija="{{.LokalnaAnimacija}}"{{end}}{{if .LokalniHover}} data-hover="{{.LokalniHover}}"{{end}}>
<body{{if .LokalnaAnimacija}} data-animacija="{{.LokalnaAnimacija}}"{{end}}{{if .LokalniHover}} data-hover="{{.LokalniHover}}"{{end}}{{if .LokalnaBrzinaAnimacije}} data-brzina-animacije="{{.LokalnaBrzinaAnimacije}}"{{end}}>
<div class="raspored">
<div class="sidebar-overlay" id="sidebar-overlay"></div>
{{template "sidebar" .}}
@@ -307,6 +315,8 @@
function ntechInicijalizuj() {
ntechKonvertujPoruke();
var m = document.querySelector('meta[name="csrf-token"]');
if (m && m.content) {
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
@@ -316,6 +326,16 @@
f.appendChild(i);
});
}
// toast pri učitavanju stranice ako URL sadrži ?sacuvano=1 (cross-page redirect)
if (!window._ntechSacuvanoPrikazano && location.search.indexOf('sacuvano=1') !== -1) {
window._ntechSacuvanoPrikazano = true;
window.ntechToast('Sačuvano', 'uspeh');
var _p = new URLSearchParams(location.search);
_p.delete('sacuvano');
var _q = _p.toString() ? '?' + _p.toString() : '';
history.replaceState(null, '', location.pathname + _q + location.hash);
}
document.querySelectorAll('[data-potvrda]').forEach(function(el) {
if (el._potvrda) return;
el._potvrda = true;
@@ -323,14 +343,87 @@
e.preventDefault();
window._ntechPotvrdi(el.getAttribute('data-potvrda')).then(function(ok) {
if (!ok) return;
// dugme unutar forme — submit forme
var forma = el.closest('form');
if (forma) { forma.submit(); return; }
// link
if (el.href) { window.location.href = el.href; }
});
});
});
// AJAX submit — forme koje serveru vraćaju ?sacuvano= ostaju na stranici (scroll se ne gubi)
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (f._ajaxSave) return;
f._ajaxSave = true;
f.addEventListener('submit', function(e) {
// file upload i forme sa data-full-reload šalju se normalno
if (f.enctype === 'multipart/form-data' || f.hasAttribute('data-full-reload')) return;
e.preventDefault();
// prikazujemo dugme kao zauzeto
var btn = f.querySelector('[type="submit"]');
if (btn) btn.disabled = true;
// URLSearchParams šalje kao application/x-www-form-urlencoded
// što Go-ov r.FormValue() (i CSRF middleware) može da pročita
fetch(f.action || location.href, {
method: 'POST',
body: new URLSearchParams(new FormData(f)),
redirect: 'follow'
}).then(function(res) {
var finUrl = new URL(res.url);
var isStiPath = finUrl.pathname === location.pathname;
var imaSacuvano = finUrl.search.indexOf('sacuvano') !== -1;
if (isStiPath && imaSacuvano) {
// uspeh na istoj stranici — prikaži toast, ostani
window.ntechToast('Sačuvano', 'uspeh');
if (btn) btn.disabled = false;
// odmah primeni podešavanja koja menjaju globalne atribute body-ja
var anim = f.querySelector('[name="lokalna_animacija"]');
if (anim) {
if (anim.value) document.body.dataset.animacija = anim.value;
else delete document.body.dataset.animacija;
}
var hov = f.querySelector('[name="lokalni_hover"]');
if (hov) {
if (hov.value) document.body.dataset.hover = hov.value;
else delete document.body.dataset.hover;
}
var brzina = f.querySelector('[name="lokalna_brzina_animacije"]');
if (brzina) {
if (brzina.value) document.body.dataset.brzinaAnimacije = brzina.value;
else delete document.body.dataset.brzinaAnimacije;
}
// toggle „Prikaži logo u gornjoj traci" — pokaži/sakrij logo u topbaru bez reloda
var logoToggle = f.querySelector('[name="topbar_logo_slika"]');
if (logoToggle) {
var logoImg = document.querySelector('.topbar-logo');
if (logoImg) logoImg.classList.toggle('skriven', !logoToggle.checked);
}
// posle čuvanja stilova lične pozadine ažuriraj CSS custom properties
var bgBlur = f.querySelector('[name="lokalna_pozadina_blur"]');
if (bgBlur) {
var r = document.documentElement;
var bgBlurBg = parseInt(f.querySelector('[name="lokalna_pozadina_blur_pozadine"]').value) || 0;
var bgOp = parseInt(f.querySelector('[name="lokalna_pozadina_opacity"]').value) || 0;
var bgGlass = parseInt(f.querySelector('[name="lokalna_pozadina_glass_opacity"]').value) || 0;
var bgBlurV = parseInt(bgBlur.value) || 0;
r.style.setProperty('--app-blur-bg', bgBlurBg + 'px');
r.style.setProperty('--app-blur-bg-inset', bgBlurBg > 0 ? '-20px' : '0');
r.style.setProperty('--app-overlay', bgOp + '%');
r.style.setProperty('--app-blur', bgBlurV + 'px');
r.style.setProperty('--app-glass-sb', bgGlass + '%');
r.style.setProperty('--app-glass-el', bgGlass + '%');
}
// promena teme zahteva reload (menja se ceo CSS fajl)
if (f.querySelector('[name="lokalna_tema"]')) location.reload();
} else {
// redirect na drugu stranicu ili bez sacuvano — navigiraj normalno
location.href = res.url;
}
}).catch(function() {
// mrežna greška — pošalji formu normalno
f.submit();
});
});
});
}
if (!window._ntechCsrfDodato) {
window._ntechCsrfDodato = true;