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.Get("/setup", h.PrikazSetupa)
r.Post("/setup", h.SacuvajSetup) r.Post("/setup", h.SacuvajSetup)
r.Get("/odjava", h.Odjava) r.Get("/odjava", h.Odjava)
r.Get("/status/{token}", h.ServisJavniStatus)
// zaštićene rute — zahtevaju prijavljenog korisnika // zaštićene rute — zahtevaju prijavljenog korisnika
r.Group(func(r chi.Router) { 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/tema", h.SacuvajLokalnuTemu)
r.With(doz("tema.lokalno")).Post("/profil/animacija", h.SacuvajLokalnuAnimaciju) 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/hover", h.SacuvajLokalniHover)
r.With(doz("tema.lokalno")).Post("/profil/brzina-animacije", h.SacuvajLokalnuBrzinuAnimacije)
r.Get("/profil/tema", h.ProfilTema) r.Get("/profil/tema", h.ProfilTema)
r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu) 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/ukloni", h.ProfilUkloniPozadinu)
+11
View File
@@ -27,6 +27,17 @@ func ProveriLozinku(hash, lozinka string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(lozinka)) == nil 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 // GenerisiToken generiše nasumičan UUID token za sesiju
func GenerisiToken() string { func GenerisiToken() string {
return uuid.New().String() return uuid.New().String()
+2
View File
@@ -112,6 +112,7 @@ type KlijentRepository interface {
type ServisRepository interface { type ServisRepository interface {
Lista(ctx context.Context, pretraga, status string) ([]model.ServisniNalogSaKlijentom, error) Lista(ctx context.Context, pretraga, status string) ([]model.ServisniNalogSaKlijentom, error)
DohvatiID(ctx context.Context, id int64) (*model.ServisniNalog, 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) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error)
Izmeni(ctx context.Context, n *model.ServisniNalog) error Izmeni(ctx context.Context, n *model.ServisniNalog) error
AzurirajStatus(ctx context.Context, id int64, status string) 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 SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error
SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error
SacuvajLokalniHover(ctx context.Context, id int64, hover 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 SacuvajAvatar(ctx context.Context, id int64, putanja string) error
PostojiIjedan(ctx context.Context) (bool, error) PostojiIjedan(ctx context.Context) (bool, error)
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
+23 -5
View File
@@ -32,6 +32,7 @@ type korisnikOpcije struct {
avatarPutanja sql.NullString avatarPutanja sql.NullString
lokalnaAnimacija sql.NullString lokalnaAnimacija sql.NullString
lokalniHover sql.NullString lokalniHover sql.NullString
lokalnaBrzinaAnimacije sql.NullString
} }
// dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik // 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.AvatarPutanja = o.avatarPutanja.String
k.LokalnaAnimacija = o.lokalnaAnimacija.String k.LokalnaAnimacija = o.lokalnaAnimacija.String
k.LokalniHover = o.lokalniHover.String k.LokalniHover = o.lokalniHover.String
k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String
} }
// skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik // 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.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
); err != nil { ); err != nil {
return nil, err 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, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), 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) FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme)
k, err := skeniraiKorisnika(row) k, err := skeniraiKorisnika(row)
if err != nil { 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, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), 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) FROM korisnici WHERE id = ?`, id)
k, err := skeniraiKorisnika(row) k, err := skeniraiKorisnika(row)
if err != nil { 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, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), 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`) FROM korisnici ORDER BY datum_kreiranja ASC`)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) 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.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
); err != nil { ); err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
} }
@@ -229,6 +234,19 @@ func (r *sqliteKorisniciRepo) SacuvajLokalniHover(ctx context.Context, id int64,
return nil 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 { 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) _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id)
if err != nil { if err != nil {
+42 -8
View File
@@ -2,13 +2,24 @@ package sqlite
import ( import (
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"encoding/hex"
"fmt" "fmt"
"time" "time"
"ntech/internal/model" "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 // ServisRepo je SQLite implementacija ServisRepository interfejsa
type ServisRepo struct { type ServisRepo struct {
db *sql.DB 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.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.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.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 COALESCE(kp.naziv, '') AS klijent_naziv
FROM servisni_nalozi sn FROM servisni_nalozi sn
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id 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, id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, status, cena_od, cena_do, cena_konacna, opis_kvara, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka, avans, napomena, garancija_do, datum_prijema, datum_zavrsetka,
ostecenja, pin_uredjaja, pribor ostecenja, pin_uredjaja, pribor, javni_token
FROM servisni_nalozi WHERE id = ?`, id) FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog var n model.ServisniNalog
@@ -102,21 +113,26 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
return &n, nil 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) { 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, ` rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO servisni_nalozi INSERT INTO servisni_nalozi
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, (klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara,
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka, status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka,
ostecenja, pin_uredjaja, pribor, datum_prijema) ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj, nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
nullString(n.SerijskiBroj), n.OpisKvara, n.Status, nullString(n.SerijskiBroj), n.OpisKvara, n.Status,
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna), nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena), nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor), nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.DatumPrijema, n.DatumPrijema, token,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err) 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 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 // 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 { func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
_, err := r.db.ExecContext(ctx, ` _, 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 // klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error { func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error {
var klijentID, tehnicarID sql.NullInt64 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 cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka sql.NullTime 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.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.Status, &cenaOd, &cenaDo, &cenaKonacna, &n.OpisKvara, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka, &avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka,
&ostecenja, &pinUredjaja, &pribor, &ostecenja, &pinUredjaja, &pribor, &javniToken,
} }
if klijentNaziv != nil { if klijentNaziv != nil {
@@ -240,6 +273,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := datumZavrsetka.Time v := datumZavrsetka.Time
n.DatumZavrsetka = &v n.DatumZavrsetka = &v
} }
n.JavniToken = javniToken.String
return nil return nil
} }
+9 -18
View File
@@ -126,8 +126,7 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Korisnik je uspešno kreiran.") http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
} }
// AdminToggleAktivan menja aktivan status korisnika // AdminToggleAktivan menja aktivan status korisnika
@@ -172,8 +171,7 @@ func (h *Handler) AdminToggleAktivan(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.") http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
} }
// AdminPromeniUlogu menja ulogu korisnika // AdminPromeniUlogu menja ulogu korisnika
@@ -234,8 +232,7 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.") http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
} }
// AdminObrisiKorisnika briše korisnika sa ulogom radnik // AdminObrisiKorisnika briše korisnika sa ulogom radnik
@@ -279,8 +276,7 @@ func (h *Handler) AdminObrisiKorisnika(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Korisnik je obrisan.") http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
} }
// AdminProfil prikazuje stranicu profila // AdminProfil prikazuje stranicu profila
@@ -406,8 +402,7 @@ func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Lozinka je uspešno promenjena.") http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
} }
// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod // 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 // isključenjem 2FA brišemo i rezervne kodove
_ = h.RezervniKodoviRepo.Obrisi(r.Context(), k.ID) _ = 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?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
} }
// AdminLoginIstorija prikazuje evidenciju prijava za datog korisnika // AdminLoginIstorija prikazuje evidenciju prijava za datog korisnika
@@ -679,8 +673,7 @@ func (h *Handler) AdminDozvolePromeniUlogu(w http.ResponseWriter, r *http.Reques
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.") http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
} }
// AdminDozvoleSacuvaj prima POST i čuva promene dozvola za radnik i admin uloge // 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?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
} }
// AdminDozvoleReset vraća sve dozvole na podrazumevane vrednosti — samo superadmin // 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) http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Dozvole su vraćene na podrazumevane vrednosti.") http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
} }
+1
View File
@@ -215,6 +215,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
if korisnik != nil { if korisnik != nil {
ps.LokalnaAnimacija = korisnik.LokalnaAnimacija ps.LokalnaAnimacija = korisnik.LokalnaAnimacija
ps.LokalniHover = korisnik.LokalniHover ps.LokalniHover = korisnik.LokalniHover
ps.LokalnaBrzinaAnimacije = korisnik.LokalnaBrzinaAnimacije
} }
return ps return ps
+24 -2
View File
@@ -5,6 +5,7 @@ import (
"html/template" "html/template"
"io/fs" "io/fs"
"log/slog" "log/slog"
"math"
"net/http" "net/http"
) )
@@ -38,13 +39,33 @@ var saSidebar = []string{
// standalone su šabloni bez base layouta // standalone su šabloni bez base layouta
var standaloneIme = []string{ 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. // sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
// dict gradi mapu iz parova ključ/vrednost — koristi se da se jednom partialu // 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)}}). // prosledi više vrednosti (npr. {{template "x" (dict "ID" .ID "Lista" $.Lista)}}).
var sablonskeFunkcije = template.FuncMap{ 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) { "dict": func(parovi ...any) (map[string]any, error) {
if len(parovi)%2 != 0 { if len(parovi)%2 != 0 {
return nil, fmt.Errorf("dict: neparan broj argumenata") 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 { 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 { if err != nil {
return nil, fmt.Errorf("kes: %s: %w", ime, err) return nil, fmt.Errorf("kes: %s: %w", ime, err)
} }
+4 -3
View File
@@ -93,10 +93,11 @@ func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) {
switch { switch {
case errors.Is(err, sqlite.ErrArtikalNePostoji): case errors.Is(err, sqlite.ErrArtikalNePostoji):
middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.") middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
case err != nil: case err != nil:
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri promeni cene.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri promeni cene.")
default:
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajna cena je izmenjena.")
}
http.Redirect(w, r, "/magacin", http.StatusSeeOther) http.Redirect(w, r, "/magacin", http.StatusSeeOther)
default:
http.Redirect(w, r, "/magacin?sacuvano=1", 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) http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Izlazni račun je dodat u KIR.") http.Redirect(w, r, "/pdv/kir?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
} }
// ObrisiPdvKir briše zapis iz KIR // 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) http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KIR.") http.Redirect(w, r, "/pdv/kir?obrisan=1", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kir", 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) http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Ulazni račun je dodat u KPR.") http.Redirect(w, r, "/pdv/kpr?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
} }
// ObrisiPdvKpr briše zapis iz KPR // 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) http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KPR.") http.Redirect(w, r, "/pdv/kpr?obrisan=1", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kpr", 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) http.Error(w, "Greška pri čuvanju PDV stope", http.StatusInternalServerError)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je dodata.") http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
} }
// IzmeniPdvStopu prima POST i menja postojeću stopu // 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) http.Error(w, "Greška pri izmeni PDV stope", http.StatusInternalServerError)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je izmenjena.") http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
} }
// PromeniAktivnostPdvStope arhivira ili vraća stopu u upotrebu (toggle, bez brisanja) // 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) http.Error(w, "Greška pri promeni statusa PDV stope", http.StatusInternalServerError)
return return
} }
poruka := "PDV stopa je arhivirana." http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
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)
} }
+17 -16
View File
@@ -267,6 +267,9 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
// whitelist dozvoljenih redirekcija — vrednost uvek dolazi iz mape, nikad od korisnika // whitelist dozvoljenih redirekcija — vrednost uvek dolazi iz mape, nikad od korisnika
dozvoljeniSledeci := map[string]string{ dozvoljeniSledeci := map[string]string{
"/admin/podesavanja/opste": "/admin/podesavanja/opste", "/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", "/podesavanja": "/podesavanja",
} }
sledeci := "/podesavanja" sledeci := "/podesavanja"
@@ -359,20 +362,20 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
// ograničavamo telo zahteva na 2MB + malo za zaglavlja forme // ograničavamo telo zahteva na 2MB + malo za zaglavlja forme
r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096) r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096)
if err := r.ParseMultipartForm(2 << 20); err != nil { 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 return
} }
fajl, zaglavlje, err := r.FormFile("logo") fajl, zaglavlje, err := r.FormFile("logo")
if err != nil { 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 return
} }
defer fajl.Close() defer fajl.Close()
// eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera) // eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera)
if zaglavlje.Size > 2<<20 { 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 return
} }
@@ -386,7 +389,7 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
} }
ocekivaniMime, ok := dozvoljenoExt[ext] ocekivaniMime, ok := dozvoljenoExt[ext]
if !ok { 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 return
} }
@@ -396,12 +399,12 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
n, _ := fajl.Read(buf) n, _ := fajl.Read(buf)
stvarniMime := http.DetectContentType(buf[:n]) stvarniMime := http.DetectContentType(buf[:n])
if !strings.HasPrefix(stvarniMime, ocekivaniMime) { 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 return
} }
// vraćamo kursor na početak // vraćamo kursor na početak
if _, err := fajl.Seek(0, io.SeekStart); err != nil { 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 return
} }
} }
@@ -416,14 +419,14 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
dst, err := os.Create(odrediste) dst, err := os.Create(odrediste)
if err != nil { if err != nil {
slog.Error("upload loga: ne mogu kreirati fajl", "error", err) 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 return
} }
defer dst.Close() defer dst.Close()
if _, err := io.Copy(dst, fajl); err != nil { if _, err := io.Copy(dst, fajl); err != nil {
slog.Error("upload loga: greška pri kopiranju", "error", err) 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 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()) 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 { if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
slog.Error("upload loga: greška pri čuvanju putanje", "error", err) 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 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 // 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) staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if stara := staraPodesavanja["login_pozadina"]; stara != "" { if stara := staraPodesavanja["login_pozadina"]; stara != "" {
// putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla // 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) staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme)) 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) podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err == nil { if err == nil {
if stara := podesavanja["login_pozadina"]; stara != "" { if stara := podesavanja["login_pozadina"]; stara != "" {
deoBezverzije := strings.Split(stara, "?")[0] deoBezverzije, _, _ := strings.Cut(stara, "?")
staroIme := filepath.Base(deoBezverzije) staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme)) os.Remove(filepath.Join("web/static/uploads", staroIme))
} }
@@ -600,8 +603,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.") http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
} }
// SacuvajLoginPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine login stranice // 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?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
} }
// napuniPodaciPodesavanja učitava sva podešavanja i kreira strukturu za template // 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) korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme)
if err != nil { 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.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false)
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "korisnik_ne_postoji", false) _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "korisnik_ne_postoji", false)
auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_user") 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 { if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err) 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)+"?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
} }
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje // renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
+63 -31
View File
@@ -15,6 +15,22 @@ import (
"ntech/internal/middleware" "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 // ProfilTema prikazuje stranicu lične teme i pozadine
func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) { func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context()) k := middleware.KorisnikIzKonteksta(r.Context())
@@ -177,8 +193,7 @@ func (h *Handler) ProfilOtpremiPozadinu(w http.ResponseWriter, r *http.Request)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
// ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika // ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika
@@ -202,8 +217,7 @@ func (h *Handler) ProfilUkloniPozadinu(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
// ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine // ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine
@@ -250,8 +264,7 @@ func (h *Handler) ProfilSacuvajPozadinuStilove(w http.ResponseWriter, r *http.Re
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Podešavanja su sačuvana.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
// SacuvajLokalnuTemu čuva korisnikovu lokalnu temu // SacuvajLokalnuTemu čuva korisnikovu lokalnu temu
@@ -284,15 +297,7 @@ func (h *Handler) SacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Tema je sačuvana.") http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil"), http.StatusSeeOther)
// 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)
} }
// SacuvajLokalnuAnimaciju čuva korisnikovu preferencu animacije tabela // 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") 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] { if !dozvoljene[animacija] {
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 { 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.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return return
} }
if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil {
middleware.SetFlash(w, r, h.DB, "uspeh", "Animacija je sačuvana.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
if ref := r.Referer(); ref != "" { http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
return 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 // SacuvajLokalniHover čuva korisnikovu preferencu hover efekta na karticama
@@ -363,14 +372,39 @@ func (h *Handler) SacuvajLokalniHover(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Hover efekat je sačuvan.") http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
if ref := r.Referer(); ref != "" { }
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), 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 return
} }
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") {
http.Error(w, "Pristup odbijen", http.StatusForbidden)
return
} }
if err := r.ParseForm(); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) 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 // ProfilOtpremiAvatar prima upload lične avatar slike korisnika
@@ -464,8 +498,7 @@ func (h *Handler) ProfilOtpremiAvatar(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je sačuvan.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
// ProfilUkloniAvatar briše ličnu avatar sliku korisnika // ProfilUkloniAvatar briše ličnu avatar sliku korisnika
@@ -489,6 +522,5 @@ func (h *Handler) ProfilUkloniAvatar(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je uklonjen.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
+38 -8
View File
@@ -444,8 +444,7 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je dodat.") http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
} }
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga // 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 { if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
slog.Error("greška pri brisanju dela", "error", err) slog.Error("greška pri brisanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.") 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) 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() 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" scheme := "http"
if r.TLS != nil { if r.TLS != nil {
scheme = "https" scheme = "https"
} }
nalogURL := scheme + "://" + r.Host + "/servis/" + strconv.FormatInt(id, 10) nalogURL := scheme + "://" + r.Host + "/status/" + nalog.JavniToken
var qrKod string var qrKod string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKod = base64.StdEncoding.EncodeToString(png) qrKod = base64.StdEncoding.EncodeToString(png)
@@ -762,7 +758,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil { if r.TLS != nil {
nalogURL += "s" nalogURL += "s"
} }
nalogURL += "://" + r.Host + "/servis/" + strconv.FormatInt(id, 10) nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
var qrKodOtpr string var qrKodOtpr string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKodOtpr = base64.StdEncoding.EncodeToString(png) 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) 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" "encoding/base64"
"net/http" "net/http"
"os" "os"
"strings"
) )
const csrfKolacic = "ntech_csrf" 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{} type csrfKljucTip struct{}
var csrfKljuc = csrfKljucTip{} var csrfKljuc = csrfKljucTip{}
@@ -52,6 +59,11 @@ func CsrfMiddleware(next http.Handler) http.Handler {
// validiramo na svim mutabilnim HTTP metodama // validiramo na svim mutabilnim HTTP metodama
switch r.Method { switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: 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) // čitamo token iz tela forme ili zaglavlja (za AJAX)
submitted := r.FormValue("_csrf") submitted := r.FormValue("_csrf")
if submitted == "" { if submitted == "" {
+1
View File
@@ -21,6 +21,7 @@ type Korisnik struct {
AvatarPutanja string AvatarPutanja string
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft" LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
LokalniHover string // "" | "bez" | "podizanje" | "svetlost" LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | "0.6" | "0.8" | "1.2" (sekunde)
} }
// Sesija predstavlja aktivnu sesiju prijavljenog korisnika // Sesija predstavlja aktivnu sesiju prijavljenog korisnika
+1
View File
@@ -46,6 +46,7 @@ type ServisniNalog struct {
Ostecenja string Ostecenja string
PinUredjaja string PinUredjaja string
Pribor string Pribor string
JavniToken string
} }
// ServisniDeo predstavlja jedan artikal ugrađen u servisni nalog // 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 AppPozadinaGlassOpacity string // vrednost 0-80 (% zatamnjivanje glass elemenata) — samo za ličnu pozadinu
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft" LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
LokalniHover string // "" | "bez" | "podizanje" | "svetlost" LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | ... | "1.5" (sekunde)
} }
// PodaciDashboarda su podaci specifični za dashboard stranicu // 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; 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 { html {
background: var(--pozadina); background: var(--pozadina);
} }
@@ -324,6 +334,8 @@ body {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.topbar-logo { height: 34px; width: auto; border-radius: 6px; flex-shrink: 0; } .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 */ /* sadržaj stranice */
.sadrzaj { .sadrzaj {
@@ -793,8 +805,8 @@ select {
animation: toastIn 0.3s ease forwards; animation: toastIn 0.3s ease forwards;
max-width: 340px; max-width: 340px;
} }
.toast-greska { background: rgba(207, 87, 87, 0.12); color: var(--greska); border: 0.5px solid var(--greska); } .toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; }
.toast-uspeh { background: var(--poruka-uspeh-bg); color: var(--poruka-uspeh-boja); border: 0.5px solid var(--poruka-uspeh-boja); } .toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; }
.toast.nestaje { animation: toastOut 0.3s ease forwards; } .toast.nestaje { animation: toastOut 0.3s ease forwards; }
@keyframes toastIn { @keyframes toastIn {
from { opacity: 0; transform: translateY(12px); } from { opacity: 0; transform: translateY(12px); }
@@ -1011,7 +1023,7 @@ select {
/* animacije */ /* animacije */
@keyframes fadeInUp { @keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@@ -1020,13 +1032,13 @@ select {
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes scaleIn { @keyframes blurIn {
from { opacity: 0; transform: scale(0.95); } from { opacity: 0; filter: blur(8px); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; filter: blur(0); }
} }
@keyframes slideLeft { @keyframes slideLeft {
from { opacity: 0; transform: translateX(-12px); } from { opacity: 0; transform: translateX(-18px); }
to { opacity: 1; transform: translateX(0); } 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, /* 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 */ ali NE zaključava krajnji transform pa .kartica:hover lift radi i na .animiraj karticama */
.animiraj { .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 */ /* kartice ne animiramo po defaultu — animacija je samo na redovima tabela */
@@ -1066,26 +1078,26 @@ select {
[data-animacija="fadeInUp"] .animiraj, [data-animacija="fadeInUp"] .animiraj,
[data-animacija="fadeInUp"] .tabela tbody tr, [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"] .animiraj,
[data-animacija="fadeIn"] .tabela tbody tr, [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="blurIn"] .animiraj,
[data-animacija="scaleIn"] .tabela tbody tr, [data-animacija="blurIn"] .tabela tbody tr,
[data-animacija="scaleIn"] .kartica.animiraj { animation: scaleIn 0.2s ease backwards; } [data-animacija="blurIn"] .kartica.animiraj { animation: blurIn var(--anim-trajanje) ease backwards; }
[data-animacija="slideLeft"] .animiraj, [data-animacija="slideLeft"] .animiraj,
[data-animacija="slideLeft"] .tabela tbody tr, [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. /* 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. 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 Mora biti u main.css: HTMX navigacija (hx-select) odbacuje <head>, pa
stilovi iz page <style> blokova ne bi važili tokom navigacije. */ stilovi iz page <style> blokova ne bi važili tokom navigacije. */
.tabela tbody tr { .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(1) { animation-delay: 0.04s; }
.tabela tbody tr:nth-child(2) { animation-delay: 0.08s; } .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-bg: rgba(93, 184, 118, 0.15);
--poruka-uspeh-boja: #5db876; --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> </svg>
</button> </button>
{{if and .TopbarLogoSlika .LogoPutanja}} {{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" class="topbar-logo"> <img src="{{.LogoPutanja}}" alt="Logo" class="topbar-logo{{if not .TopbarLogoSlika}} skriven{{end}}">
{{end}} {{end}}
<span class="topbar-naslov">{{.NaslovStranice}}</span> <span class="topbar-naslov">{{.NaslovStranice}}</span>
+38 -10
View File
@@ -83,24 +83,23 @@
<form method="POST" action="/profil/animacija"> <form method="POST" action="/profil/animacija">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}"> <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 class="kolona" style="gap:16px;">
<div style="max-width:300px;"> <div style="max-width:300px;">
<label class="polje-labela" for="anim-select">Vrsta animacije pri učitavanju</label> <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="" {{if eq .LokalnaAnimacija ""}}selected{{end}}>Podrazumevano (klizanje gore)</option>
<option value="bez" {{if eq .LokalnaAnimacija "bez"}}selected{{end}}>Bez animacije</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="fadeInUp" {{if eq .LokalnaAnimacija "fadeInUp"}}selected{{end}}>Klizanje gore</option>
<option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje (fadeIn)</option> <option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje</option>
<option value="scaleIn" {{if eq .LokalnaAnimacija "scaleIn"}}selected{{end}}>Zumiranje (scaleIn)</option> <option value="blurIn" {{if eq .LokalnaAnimacija "blurIn"}}selected{{end}}>Zamagljivanje</option>
<option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva (slideLeft)</option> <option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva</option>
</select> </select>
</div> </div>
<!-- preview --> <!-- preview -->
<div> <div>
<div class="pomocni-tekst" style="margin-bottom:8px;">Pregled:</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;"> <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;"> <table style="width:100%;border-collapse:collapse;">
<tbody id="anim-preview-body"> <tbody id="anim-preview-body">
@@ -116,6 +115,20 @@
</button> </button>
</div> </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> <div>
<button type="submit" class="btn-primarno">Sačuvaj</button> <button type="submit" class="btn-primarno">Sačuvaj</button>
</div> </div>
@@ -376,7 +389,7 @@ var animVrednosti = {
'bez': 'bez', 'bez': 'bez',
'fadeInUp': 'fadeInUp', 'fadeInUp': 'fadeInUp',
'fadeIn': 'fadeIn', 'fadeIn': 'fadeIn',
'scaleIn': 'scaleIn', 'blurIn': 'blurIn',
'slideLeft': 'slideLeft' 'slideLeft': 'slideLeft'
}; };
// postavlja data-animacija na PREVIEW wrapper (ne na body), pa CSS // 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'); var wrap = document.getElementById('anim-preview-wrap');
if (!wrap) return; if (!wrap) return;
var anim = animVrednosti[val] || 'fadeInUp'; 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'); 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); wrap.setAttribute('data-animacija', anim);
redovi.forEach(function(r) { r.classList.add('animiraj'); });
} }
// "Ponovi pregled" — ponovo okida animaciju za trenutno izabrani stil // "Ponovi pregled" — ponovo okida animaciju za trenutno izabrani stil
function animPreviewPonovi() { function animPreviewPonovi() {
@@ -399,5 +415,17 @@ function animPreviewPonovi() {
var sel = document.getElementById('anim-select'); var sel = document.getElementById('anim-select');
if (sel) animPreview(sel.value); 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> </script>
{{end}} {{end}}
@@ -57,12 +57,16 @@
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <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"> <a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno">
Radni nalog Radni nalog
</a> </a>
{{end}}
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno"> <a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
Otpremnica Otpremnica
</a> </a>
{{end}}
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno"> <a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno">
Izmeni nalog Izmeni nalog
</a> </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}} {{if .AppPozadina}}
<style> <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 { html {
background: url('{{.AppPozadina}}') center/cover fixed; background: url('{{.AppPozadina}}') center/cover fixed;
background-color: #1f2228; background-color: #1f2228;
@@ -36,9 +44,9 @@
body::before { body::before {
content: ''; content: '';
position: fixed; position: fixed;
inset: {{if ne .AppPozadinaBlurPozadine "0"}}-20px{{else}}0{{end}}; inset: var(--app-blur-bg-inset);
background: url('{{.AppPozadina}}') center/cover; background: url('{{.AppPozadina}}') center/cover;
filter: blur({{.AppPozadinaBlurPozadine}}px); filter: blur(var(--app-blur-bg));
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
} }
@@ -46,18 +54,18 @@
content: ''; content: '';
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0,0,0,{{.AppPozadinaOpacity}}%); background: rgba(0,0,0,var(--app-overlay));
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
.raspored { position: relative; z-index: 2; } .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, .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-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; } .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); } .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 { background: rgba(0,0,0,0.38) !important; }
body:not([data-hover]) .kartica:hover .dash-ikona { filter: brightness(0.55); } 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); } .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 */ dobijaju sopstvenu staklenu podlogu da se vide bez obzira na sliku ispod */
.nazad-link, .btn-sekundarno, .btn-obrisi-ghost, .cek-filter { .nazad-link, .btn-sekundarno, .btn-obrisi-ghost, .cek-filter {
background: rgba(0,0,0,0.28) !important; 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; 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); color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 3px rgba(0,0,0,0.8);
} }
@@ -110,7 +118,7 @@
</script> </script>
{{end}} {{end}}
</head> </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="raspored">
<div class="sidebar-overlay" id="sidebar-overlay"></div> <div class="sidebar-overlay" id="sidebar-overlay"></div>
{{template "sidebar" .}} {{template "sidebar" .}}
@@ -307,6 +315,8 @@
function ntechInicijalizuj() { function ntechInicijalizuj() {
ntechKonvertujPoruke(); ntechKonvertujPoruke();
var m = document.querySelector('meta[name="csrf-token"]'); var m = document.querySelector('meta[name="csrf-token"]');
if (m && m.content) { if (m && m.content) {
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) { document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
@@ -316,6 +326,16 @@
f.appendChild(i); 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) { document.querySelectorAll('[data-potvrda]').forEach(function(el) {
if (el._potvrda) return; if (el._potvrda) return;
el._potvrda = true; el._potvrda = true;
@@ -323,14 +343,87 @@
e.preventDefault(); e.preventDefault();
window._ntechPotvrdi(el.getAttribute('data-potvrda')).then(function(ok) { window._ntechPotvrdi(el.getAttribute('data-potvrda')).then(function(ok) {
if (!ok) return; if (!ok) return;
// dugme unutar forme — submit forme
var forma = el.closest('form'); var forma = el.closest('form');
if (forma) { forma.submit(); return; } if (forma) { forma.submit(); return; }
// link
if (el.href) { window.location.href = el.href; } 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) { if (!window._ntechCsrfDodato) {
window._ntechCsrfDodato = true; window._ntechCsrfDodato = true;