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
This commit is contained in:
2026-06-20 13:04:23 +02:00
parent f7a5d2673b
commit 2937acfcc1
15 changed files with 335 additions and 47 deletions
+1
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) {
+1
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
+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
} }
+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)
} }
+3 -2
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.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
default: 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) 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)
} }
+2 -4
View File
@@ -600,8 +600,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 +658,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
+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
+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,
})
}
+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
+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);
@@ -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>
+5 -3
View File
@@ -351,8 +351,10 @@
redirect: 'follow' redirect: 'follow'
}).then(function(res) { }).then(function(res) {
var finUrl = new URL(res.url); var finUrl = new URL(res.url);
if (finUrl.search.indexOf('sacuvano') !== -1) { var isStiPath = finUrl.pathname === location.pathname;
// uspeh — prikaži toast, ostani na stranici var imaSacuvano = finUrl.search.indexOf('sacuvano') !== -1;
if (isStiPath && imaSacuvano) {
// uspeh na istoj stranici — prikaži toast, ostani
window.ntechToast('Sačuvano', 'uspeh'); window.ntechToast('Sačuvano', 'uspeh');
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
// odmah primeni podešavanja koja menjaju globalne atribute body-ja // odmah primeni podešavanja koja menjaju globalne atribute body-ja
@@ -374,7 +376,7 @@
// promena teme zahteva reload (menja se ceo CSS fajl) // promena teme zahteva reload (menja se ceo CSS fajl)
if (f.querySelector('[name="lokalna_tema"]')) location.reload(); if (f.querySelector('[name="lokalna_tema"]')) location.reload();
} else { } else {
// greška ili redirect na drugu stranicu — navigiraj normalno // redirect na drugu stranicu ili bez sacuvano — navigiraj normalno
location.href = res.url; location.href = res.url;
} }
}).catch(function() { }).catch(function() {