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
+24 -2
View File
@@ -5,6 +5,7 @@ import (
"html/template"
"io/fs"
"log/slog"
"math"
"net/http"
)
@@ -38,13 +39,33 @@ var saSidebar = []string{
// standalone su šabloni bez base layouta
var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica",
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_status_javni",
}
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
// dict gradi mapu iz parova ključ/vrednost — koristi se da se jednom partialu
// prosledi više vrednosti (npr. {{template "x" (dict "ID" .ID "Lista" $.Lista)}}).
var sablonskeFunkcije = template.FuncMap{
// formatBroj formatira float pointer kao ceo broj (zaokružen) — nil vraca ""
"formatBroj": func(v *float64) string {
if v == nil {
return ""
}
return fmt.Sprintf("%d", int64(math.Round(*v)))
},
// statusPre vraća true ako je `a` pre `b` u redosledu statusa
"statusPre": func(a, b string, statusi []string) bool {
ia, ib := -1, -1
for i, s := range statusi {
if s == a {
ia = i
}
if s == b {
ib = i
}
}
return ia >= 0 && ib >= 0 && ia < ib
},
"dict": func(parovi ...any) (map[string]any, error) {
if len(parovi)%2 != 0 {
return nil, fmt.Errorf("dict: neparan broj argumenata")
@@ -77,7 +98,8 @@ func KreirajKes(fsys fs.FS) (map[string]*template.Template, error) {
}
for _, ime := range standaloneIme {
t, err := template.ParseFS(fsys, "web/templates/stranice/"+ime+".html")
// ime+".html" mora biti ime roota da bi Execute() pronašlo sadržaj fajla
t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, "web/templates/stranice/"+ime+".html")
if err != nil {
return nil, fmt.Errorf("kes: %s: %w", ime, err)
}
+3 -2
View File
@@ -93,10 +93,11 @@ func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) {
switch {
case errors.Is(err, sqlite.ErrArtikalNePostoji):
middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
case err != nil:
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri promeni cene.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
default:
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajna cena je izmenjena.")
http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther)
}
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
}
+2 -4
View File
@@ -169,8 +169,7 @@ func (h *Handler) SacuvajPdvKir(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Izlazni račun je dodat u KIR.")
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kir?sacuvano=1", http.StatusSeeOther)
}
// ObrisiPdvKir briše zapis iz KIR
@@ -187,6 +186,5 @@ func (h *Handler) ObrisiPdvKir(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KIR.")
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kir?obrisan=1", http.StatusSeeOther)
}
+2 -4
View File
@@ -154,8 +154,7 @@ func (h *Handler) SacuvajPdvKpr(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Ulazni račun je dodat u KPR.")
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kpr?sacuvano=1", http.StatusSeeOther)
}
// ObrisiPdvKpr briše zapis iz KPR
@@ -172,6 +171,5 @@ func (h *Handler) ObrisiPdvKpr(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KPR.")
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kpr?obrisan=1", http.StatusSeeOther)
}
+3 -10
View File
@@ -99,8 +99,7 @@ func (h *Handler) DodajPdvStopu(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju PDV stope", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je dodata.")
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
// IzmeniPdvStopu prima POST i menja postojeću stopu
@@ -128,8 +127,7 @@ func (h *Handler) IzmeniPdvStopu(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri izmeni PDV stope", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je izmenjena.")
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
// PromeniAktivnostPdvStope arhivira ili vraća stopu u upotrebu (toggle, bez brisanja)
@@ -151,10 +149,5 @@ func (h *Handler) PromeniAktivnostPdvStope(w http.ResponseWriter, r *http.Reques
http.Error(w, "Greška pri promeni statusa PDV stope", http.StatusInternalServerError)
return
}
poruka := "PDV stopa je arhivirana."
if !postojeca.Aktivna {
poruka = "PDV stopa je vraćena u upotrebu."
}
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
+2 -4
View File
@@ -600,8 +600,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.")
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
}
// SacuvajLoginPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine login stranice
@@ -659,8 +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", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
}
// 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 {
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
+38 -8
View File
@@ -444,8 +444,7 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je dodat.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga
@@ -470,10 +469,7 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
slog.Error("greška pri brisanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.")
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je uklonjen.")
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
}
@@ -647,12 +643,12 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
ukupnoDelovi += d.Ukupno()
}
// QR kod sadrži URL naloga — isti host kao što korisnik koristi
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
nalogURL := scheme + "://" + r.Host + "/servis/" + strconv.FormatInt(id, 10)
nalogURL := scheme + "://" + r.Host + "/status/" + nalog.JavniToken
var qrKod string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKod = base64.StdEncoding.EncodeToString(png)
@@ -762,7 +758,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
nalogURL += "s"
}
nalogURL += "://" + r.Host + "/servis/" + strconv.FormatInt(id, 10)
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
var qrKodOtpr string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKodOtpr = base64.StdEncoding.EncodeToString(png)
@@ -813,3 +809,37 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
type PodaciJavnogStatusa struct {
Nalog model.ServisniNalog
NazivFirme string
Telefon string
Adresa string
SviStatusi []string
}
// ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda
func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
http.NotFound(w, r)
return
}
nalog, err := h.ServisRepo.DohvatiJavniToken(r.Context(), token)
if err != nil {
http.NotFound(w, r)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
Nalog: *nalog,
NazivFirme: podesavanja["naziv_firme"],
Telefon: podesavanja["telefon"],
Adresa: podesavanja["adresa"],
SviStatusi: model.SviStatusi,
})
}