Servis: auto-snimanje forme izmene + hx-boost navigacija (bez bljeska)

- Forma Izmeni nalog snima se automatski (change na celoj formi, X-Autosave→204), bez dugmeta Sačuvaj
- Ispravka: trazene_nadogradnje je NOT NULL — slati prazan string umesto NULL
- Iz kartice detalja uklonjeni dropdown statusa i datum završetka
- Forma izmene više ne ubacuje težak JSON svih klijenata (nepotreban u izmeni)
- Navigacioni linkovi (Kategorije, Izmeni, Nazad) dobili hx-boost — swap #glavni-sadrzaj bez punog reloda, pozadina ne trepće
This commit is contained in:
2026-06-23 01:08:14 +02:00
parent cd836d09c4
commit 69bfaef0d9
6 changed files with 96 additions and 37 deletions
+2 -2
View File
@@ -131,7 +131,7 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token) 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, nullString(n.TrazeneNadogradnje), n.Status, nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje, 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),
@@ -176,7 +176,7 @@ func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?, avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?,
ostecenja = ?, pin_uredjaja = ?, pribor = ? ostecenja = ?, pin_uredjaja = ?, pribor = ?
WHERE id = ?`, WHERE id = ?`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara, nullString(n.TrazeneNadogradnje), nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje,
n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna), n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor), nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
+20
View File
@@ -304,8 +304,16 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
return return
} }
// auto-snimanje (forma se snima u realnom vremenu): umesto redirecta vraćamo 204,
// a greške kao kratak tekst sa statusom — JS na stranici to prikaže korisniku
autosave := r.Header.Get("X-Autosave") == "1"
nalog, greska := parseFormuNaloga(r) nalog, greska := parseFormuNaloga(r)
if greska != "" { if greska != "" {
if autosave {
http.Error(w, greska, http.StatusBadRequest)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context()) tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
@@ -342,6 +350,10 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
// ako nije izabran postojeći klijent, eventualno kreiraj novog iz polja forme // ako nije izabran postojeći klijent, eventualno kreiraj novog iz polja forme
if greska := h.mozdaKreirajKlijenta(r.Context(), r, &nalog); greska != "" { if greska := h.mozdaKreirajKlijenta(r.Context(), r, &nalog); greska != "" {
if autosave {
http.Error(w, greska, http.StatusBadRequest)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context()) tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
@@ -362,6 +374,10 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
if err := h.ServisRepo.Izmeni(r.Context(), &nalog); err != nil { if err := h.ServisRepo.Izmeni(r.Context(), &nalog); err != nil {
slog.Error("greška pri čuvanju izmene naloga", "error", err) slog.Error("greška pri čuvanju izmene naloga", "error", err)
if autosave {
http.Error(w, "Došlo je do greške pri čuvanju.", http.StatusInternalServerError)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context()) tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
@@ -380,6 +396,10 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
return return
} }
if autosave {
w.WriteHeader(http.StatusNoContent)
return
}
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)
} }
+1 -1
View File
@@ -27,7 +27,7 @@
{{end}} {{end}}
<!-- nazad dugme --> <!-- nazad dugme -->
<a href="/magacin" class="nazad-link"> <a href="/magacin" class="nazad-link" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
Nazad na magacin Nazad na magacin
</a> </a>
+1 -1
View File
@@ -30,7 +30,7 @@
<a href="/magacin/novi?tip={{.Tip}}" class="btn-primarno">+ {{if eq .Tip "usluga"}}Nova usluga{{else if eq .Tip "trosak"}}Novi trošak{{else}}Novi artikal{{end}}</a> <a href="/magacin/novi?tip={{.Tip}}" class="btn-primarno">+ {{if eq .Tip "usluga"}}Nova usluga{{else if eq .Tip "trosak"}}Novi trošak{{else}}Novi artikal{{end}}</a>
{{end}} {{end}}
{{if and (eq .Tip "proizvod") (index .Dozvole "kategorija.pregled")}} {{if and (eq .Tip "proizvod") (index .Dozvole "kategorija.pregled")}}
<a href="/magacin/kategorije" class="btn-primarno">Kategorije</a> <a href="/magacin/kategorije" class="btn-primarno" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">Kategorije</a>
{{end}} {{end}}
</div> </div>
+2 -24
View File
@@ -30,7 +30,7 @@
{{end}} {{end}}
<!-- nazad dugme --> <!-- nazad dugme -->
<a href="/servis" class="nazad-link"> <a href="/servis" class="nazad-link" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
Nazad na servis Nazad na servis
</a> </a>
@@ -44,22 +44,6 @@
</span> </span>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
{{template "status-badge-detalji" .Nalog.Status}} {{template "status-badge-detalji" .Nalog.Status}}
<form method="post" action="/servis/{{.Nalog.ID}}/status" style="display:flex;align-items:center;gap:6px;" data-refresh="#nalog-zaglavlje">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<select name="status" style="font-size:13px;padding:4px 8px;border-radius:6px;border:0.5px solid var(--ivica);background:var(--kartica-pozadina);color:var(--tekst-glavni);cursor:pointer;"{{if .ZakljucanStatus}} disabled title="Status zaključan dok ima delova koji nedostaju"{{end}}>
{{range .SviStatusi}}
{{/* „Čeka delove" je automatski status — ne nudi se ručno, osim ako je nalog već u njemu */}}
{{if or (ne . "Čeka delove") (eq . $.Nalog.Status)}}
<option value="{{.}}"{{if eq . $.Nalog.Status}} selected{{end}}>{{.}}</option>
{{end}}
{{end}}
</select>
{{if .ZakljucanStatus}}
<span style="font-size:12px;color:#dc2626;">🔒 Status zaključan — obrišite delove koji nedostaju</span>
{{else}}
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:4px 12px;">Promeni</button>
{{end}}
</form>
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;flex-wrap:wrap;">
@@ -76,7 +60,7 @@
Otpremnica Otpremnica
</a> </a>
{{end}} {{end}}
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno"> <a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
Izmeni nalog Izmeni nalog
</a> </a>
</div> </div>
@@ -92,12 +76,6 @@
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.PredvidjenDatum.Format "02.01.2006."}}</div> <div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.PredvidjenDatum.Format "02.01.2006."}}</div>
</div> </div>
{{end}} {{end}}
{{if .Nalog.DatumZavrsetka}}
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Datum završetka</div>
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</div>
</div>
{{end}}
<div> <div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija do</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija do</div>
<div style="font-size:14px;{{if .Nalog.GarancijaDo}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}"> <div style="font-size:14px;{{if .Nalog.GarancijaDo}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}">
+70 -9
View File
@@ -5,14 +5,18 @@
{{define "sadrzaj"}} {{define "sadrzaj"}}
<div style="width:100%;"> <div style="width:100%;">
<!-- podaci za Alpine: lista klijenata i eventualno već vezani klijent (kod izmene) --> {{if not .Izmena}}
<!-- podaci za Alpine pretragu klijenta — potrebni samo pri novom nalogu;
u izmeni se blok klijenta ne prikazuje, pa ne ubacujemo težak inline JSON svih klijenata
(blokirao bi render forme i pravio bljesak pozadine pri otvaranju) -->
<script> <script>
window.__klijentiNaloga = {{json .Klijenti}}; window.__klijentiNaloga = {{json .Klijenti}};
window.__izabraniKlijentId = {{.Nalog.KlijentIDVrednost}}; window.__izabraniKlijentId = {{.Nalog.KlijentIDVrednost}};
</script> </script>
{{end}}
<!-- nazad dugme --> <!-- nazad dugme -->
<a href="{{if .Izmena}}/servis/{{.Nalog.ID}}{{else}}/servis{{end}}" class="nazad-link"> <a href="{{if .Izmena}}/servis/{{.Nalog.ID}}{{else}}/servis{{end}}" class="nazad-link" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
{{if .Izmena}}Nazad na nalog{{else}}Nazad na servis{{end}} {{if .Izmena}}Nazad na nalog{{else}}Nazad na servis{{end}}
</a> </a>
@@ -21,7 +25,7 @@
<div class="poruka-greska greska-animacija" style="margin-bottom:16px;">{{.Greska}}</div> <div class="poruka-greska greska-animacija" style="margin-bottom:16px;">{{.Greska}}</div>
{{end}} {{end}}
<form method="POST" action="{{if .Izmena}}/servis/izmeni/{{.Nalog.ID}}{{else}}/servis/novi{{end}}"> <form id="forma-nalog" method="POST" action="{{if .Izmena}}/servis/izmeni/{{.Nalog.ID}}{{else}}/servis/novi{{end}}"{{if .Izmena}} data-autosave="1"{{end}}>
<!-- broj naloga ide kao hidden — server ga je generisao, vraćamo ga pri POST-u --> <!-- broj naloga ide kao hidden — server ga je generisao, vraćamo ga pri POST-u -->
<input type="hidden" name="broj_naloga" value="{{.Nalog.BrojNaloga}}"> <input type="hidden" name="broj_naloga" value="{{.Nalog.BrojNaloga}}">
@@ -304,18 +308,75 @@
style="width:100%;resize:vertical;">{{.Nalog.Napomena}}</textarea> style="width:100%;resize:vertical;">{{.Nalog.Napomena}}</textarea>
</div> </div>
<!-- dugmad --> {{if .Izmena}}
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:18px;"> <!-- izmena se snima automatski u realnom vremenu; indikator stanja umesto dugmeta -->
<a href="{{if .Izmena}}/servis/{{.Nalog.ID}}{{else}}/servis{{end}}" class="btn-sekundarno">Odustani</a> <div style="display:flex;justify-content:flex-end;align-items:center;min-height:24px;margin-top:18px;">
<button type="submit" class="btn-primarno"> <span id="autosave-status" style="font-size:13px;color:var(--tekst-sporedni);transition:opacity 0.3s;opacity:0;"></span>
{{if .Izmena}}Sačuvaj izmene{{else}}Sačuvaj nalog{{end}}
</button>
</div> </div>
{{else}}
<!-- dugmad (samo za novi nalog) -->
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:18px;">
<a href="/servis" class="btn-sekundarno">Odustani</a>
<button type="submit" class="btn-primarno">Sačuvaj nalog</button>
</div>
{{end}}
</div> </div>
</form> </form>
</div> </div>
<script>
// automatsko snimanje forme „Izmeni nalog" — okida se na `change` bilo kog polja.
// `change` se za tekst/textarea/number okine na blur (samo ako je vrednost promenjena),
// a za select/date/checkbox na izbor — jedan slušalac pokriva sve tipove.
(function () {
var forma = document.getElementById('forma-nalog');
if (!forma || forma.dataset.autosave !== '1') return;
var status = document.getElementById('autosave-status');
var slanjeUToku = false;
var ponoviPoZavrsetku = false;
function prikaziStatus(tekst, boja) {
if (!status) return;
status.textContent = tekst;
status.style.color = boja;
status.style.opacity = '1';
}
function sakrijStatus() {
if (status) status.style.opacity = '0';
}
function snimi() {
// ako je slanje već u toku, zapamti da treba ponovo snimiti kad se završi
if (slanjeUToku) { ponoviPoZavrsetku = true; return; }
slanjeUToku = true;
prikaziStatus('Čuvam…', 'var(--tekst-sporedni)');
var podaci = new URLSearchParams(new FormData(forma));
fetch(forma.action, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Autosave': '1' },
body: podaci,
}).then(function (odgovor) {
if (odgovor.ok) {
prikaziStatus('Sačuvano ✓', '#16a34a');
setTimeout(sakrijStatus, 2000);
} else {
return odgovor.text().then(function (poruka) {
prikaziStatus(poruka.trim() || 'Greška pri čuvanju', '#dc2626');
});
}
}).catch(function () {
prikaziStatus('Greška pri čuvanju', '#dc2626');
}).finally(function () {
slanjeUToku = false;
if (ponoviPoZavrsetku) { ponoviPoZavrsetku = false; snimi(); }
});
}
forma.addEventListener('change', snimi);
})();
</script>
<style> <style>
.tip-opcija { .tip-opcija {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;