Merge grane feature/storno-modal-i-fiskalni-tab: storno modal, about:blank ispravka, dnevni pazar modul, štampaj kopiju u prodaji

This commit is contained in:
2026-07-03 02:25:05 +02:00
6 changed files with 161 additions and 49 deletions
+17
View File
@@ -9,6 +9,7 @@ import (
"time"
"unicode"
"ntech/internal/config"
ntechsqlite "ntech/internal/db/sqlite"
"ntech/internal/fiskal"
"ntech/internal/middleware"
@@ -56,6 +57,12 @@ func (h *Handler) FiskalniPazar(w http.ResponseWriter, r *http.Request) {
podaci := PodaciFiskalniPazar{PodaciStranice: ps, Danas: time.Now().Format("2006-01-02")}
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
podaci.Greska = "Fiskalizacija nije uključena u profilu firme — Podešavanja → Opšte → Pravni i poreski status."
h.renderujTemplate(w, "fiskal_pazar", podaci)
return
}
klijent := h.fiskalKlijent()
if klijent == nil {
podaci.Greska = "Fiskalizacija nije podešena — unesi URL PFR servera u Podešavanja → Fiskalizacija."
@@ -82,6 +89,11 @@ func (h *Handler) ZakljuciFiskalniDan(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.zakljucenje"); !ok {
return
}
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije uključena u profilu firme.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
klijent := h.fiskalKlijent()
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
@@ -110,6 +122,11 @@ func (h *Handler) FiskalniIzvestaj(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije uključena u profilu firme.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
klijent := h.fiskalKlijent()
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
+4
View File
@@ -226,6 +226,10 @@ document.addEventListener('alpine:init', () => {
this.artikliOpcije = window._ntechArtikli || []
this.klijenti = window._ntechKlijenti || []
this.pdvObveznik = window._ntechPdvObveznik === true
// ako firma ne fiskalizuje, ne otvaramo ni prazan tab u pripremiFiskalniTab() —
// stranica detalja nikad neće imati FiskalniRacun da ga popuni, pa bi ostao
// trajno na about:blank (v. docs/Greške.md)
this.prikaziRacun = window._ntechFiskalizacija === true
this.isMobile = window.matchMedia('(max-width: 768px)').matches
window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
this.isMobile = e.matches
+1 -1
View File
@@ -100,7 +100,7 @@
{{/* Knjigovodstvo — grupa: KIR/KPR/Obračun (modul „pdv") + Nivelacije (dozvola izmene cena) */}}
{{$pdvVid := and (index .Moduli "pdv") (index .Dozvole "pdv.pregled")}}
{{$kpoVid := and (index .Moduli "kpo") (index .Dozvole "kpo.pregled")}}
{{$pazarVid := index .Dozvole "fiskal.pazar"}}
{{$pazarVid := and (index .Moduli "fiskalizacija") (index .Dozvole "fiskal.pazar")}}
{{$knjVid := or $pdvVid $kpoVid $pazarVid (index .Dozvole "artikal.izmeni")}}
{{if $knjVid}}
{{$knjAktivan := or (eq .Stranica "nivelacije") (eq .Stranica "kpo") (eq .Stranica "pdv-kir") (eq .Stranica "pdv-kpr") (eq .Stranica "pdv-obracun") (eq .Stranica "fiskal-pazar")}}
+67 -35
View File
@@ -52,29 +52,6 @@
</span>
{{end}}
</div>
<a
href="/prodaja/{{.Nalog.ID}}/stampa"
target="_blank"
class="btn-sekundarno"
style="gap: 6px">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9" />
<path
d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Štampaj
</a>
</div>
<div
style="
@@ -252,23 +229,66 @@
Storno vraća sve artikle na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
</div>
</div>
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="text" name="razlog" placeholder="Razlog storniranja (opciono)" style="width:240px;">
{{if .PotrebnaIdentKupca}}
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="width:240px;">
{{end}}
<button type="submit" class="btn-upozorenje"
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}?">
Storniraj nalog
</button>
</form>
<button type="button" class="btn-upozorenje" onclick="ntechPotvrdiPaOtvoriStorno(this, 'Stornirati nalog {{.Nalog.BrojNaloga}}?')">
Storniraj nalog
</button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Storniranje naloga {{.Nalog.BrojNaloga}}</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;">
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" data-refresh="#sadrzaj" style="display:flex;flex-direction:column;gap:10px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div>
<label class="polje-labela">Razlog storniranja (opciono)</label>
<input type="text" name="razlog" placeholder="Razlog storniranja" style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
{{if .PotrebnaIdentKupca}}
<div>
<label class="polje-labela">PIB ili JMBG kupca</label>
<input type="text" id="storno-pib" name="poreski_broj_kupca" placeholder="Obavezno za fiskalni refund" required
style="width:100%;padding:8px 12px;border:1px solid #dc2626;border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
{{end}}
<button type="submit" id="btn-storno-potvrdi" class="btn-upozorenje" style="width:100%;{{if .PotrebnaIdentKupca}}opacity:0.4;cursor:not-allowed;{{end}}" {{if .PotrebnaIdentKupca}}disabled{{end}}>
Potvrdi storno
</button>
</form>
</div>
</dialog>
</div>
<script>
(function(){
// isti obrazac kao u Servisu (servis_detalji.html) — v. komentar tamo zašto
// se NE koristi ntechOtvoriModal (auto-submit na zatvaranje dialoga).
window.ntechPotvrdiPaOtvoriStorno = function (btn, poruka) {
window._ntechPotvrdi(poruka).then(function (ok) {
if (!ok) return;
var d = btn.nextElementSibling;
if (d && d.tagName === 'DIALOG') d.showModal();
});
};
var pib = document.getElementById('storno-pib');
var btnPotvrdi = document.getElementById('btn-storno-potvrdi');
if (pib && btnPotvrdi) {
pib.addEventListener('input', function () {
var ok = pib.value.trim() !== '';
btnPotvrdi.disabled = !ok;
btnPotvrdi.style.opacity = ok ? '1' : '0.4';
btnPotvrdi.style.cursor = ok ? 'pointer' : 'not-allowed';
});
}
})();
</script>
{{end}}
{{if .FiskalniRacun}}
<div class="fiskalni-racun" style="padding-top:14px;border-top:0.5px solid var(--ivica);">
<h3 style="margin:0 0 8px 0;font-size:16px;">Fiskalni račun</h3>
<div style="margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:16px;">Fiskalni račun</h3>
<a href="/prodaja/{{.Nalog.ID}}/fiskalni-racun" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
</div>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;">
<div style="flex:1;min-width:200px;">
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.FiskalniRacun.PfrBroj}}</p>
@@ -296,6 +316,18 @@
}
})();
</script>
{{else}}
<script>
(function(){
// fiskalizacija je pokušana (racun=1 tražen pri naplati) ali računa nema —
// PFR nedostupan ili slično (v. FiskalGreska ispod). Prazan tab je već otvoren
// pre redirekta — zatvori ga umesto da ostane trajno na about:blank.
if (new URLSearchParams(location.search).get('racun') === '1') {
var t = window.open('', 'ntech-fiskalni-tab');
if (t) t.close();
}
})();
</script>
{{end}}
{{if .FiskalGreska}}
@@ -21,6 +21,7 @@
var _ntechArtikli = {{.ArtikliJSON}};
var _ntechKlijenti = {{.KlijentiJSON}};
var _ntechPdvObveznik = {{if index .Moduli "pdv"}}true{{else}}false{{end}};
var _ntechFiskalizacija = {{if index .Moduli "fiskalizacija"}}true{{else}}false{{end}};
</script>
<div style="width: 100%" x-data="prodajaForma">
+71 -13
View File
@@ -214,13 +214,27 @@
f.addEventListener('submit', function(){
var cb = f.querySelector('#prikazi-racun');
if (!cb || !cb.checked) return;
// otvori prazan imenovani tab SINHRONO unutar submit-a (očuvan user
// gesture) — isti obrazac kao pripremiFiskalniTab u Prodaji (ntech.js).
// Naplata ovde ide preko AJAX-a (data-refresh), pa se popunjava tek
// kad MutationObserver javi da je swap gotov — a to je već van user
// gesture-a, pa direktan window.open() u tom trenutku brauzer tretira
// kao blokiran popup i otvara about:blank (v. docs/Greške.md).
var tab = window.open('', 'ntech-fiskalni-tab');
var sadrzaj = document.getElementById('sadrzaj');
if (!sadrzaj) return;
if (!sadrzaj) {
if (tab) tab.close();
return;
}
var observer = new MutationObserver(function(){
observer.disconnect();
// jedan prozor — server u istu stranicu ubacuje i povraćaj viška
// avansa (ako postoji), da brauzer ne blokira drugi popup
window.open('/servis/{{.Nalog.ID}}/fiskalni-racun', '_blank');
if (tab && !tab.closed) {
tab.location.href = '/servis/{{.Nalog.ID}}/fiskalni-racun';
} else {
window.open('/servis/{{.Nalog.ID}}/fiskalni-racun', '_blank');
}
});
observer.observe(sadrzaj.parentElement, {childList: true});
});
@@ -1124,19 +1138,63 @@
Storno vraća ugrađene delove na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
</div>
</div>
<form method="POST" action="/servis/storno/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="text" name="razlog" placeholder="Razlog storniranja (opciono)" style="width:240px;">
{{if .PotrebnaIdentKupca}}
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="width:240px;">
{{end}}
<button type="submit" class="btn-upozorenje"
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}?">
Storniraj nalog
</button>
</form>
<button type="button" class="btn-upozorenje" onclick="ntechPotvrdiPaOtvoriStorno(this, 'Stornirati nalog {{.Nalog.BrojNaloga}}?')">
Storniraj nalog
</button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Storniranje naloga {{.Nalog.BrojNaloga}}</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;">
<form method="POST" action="/servis/storno/{{.Nalog.ID}}" data-refresh="#sadrzaj" style="display:flex;flex-direction:column;gap:10px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div>
<label class="polje-labela">Razlog storniranja (opciono)</label>
<input type="text" name="razlog" placeholder="Razlog storniranja" style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
{{if .PotrebnaIdentKupca}}
<div>
<label class="polje-labela">PIB ili JMBG kupca</label>
<input type="text" id="storno-pib" name="poreski_broj_kupca" placeholder="Obavezno za fiskalni refund" required
style="width:100%;padding:8px 12px;border:1px solid #dc2626;border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
{{end}}
<button type="submit" id="btn-storno-potvrdi" class="btn-upozorenje" style="width:100%;{{if .PotrebnaIdentKupca}}opacity:0.4;cursor:not-allowed;{{end}}" {{if .PotrebnaIdentKupca}}disabled{{end}}>
Potvrdi storno
</button>
</form>
</div>
</dialog>
</div>
</div>
<script>
(function(){
// storno: potvrda namere PRE otvaranja modala (dvostepeni tok), pa tek onda
// polje za PIB/JMBG (obavezno samo ako je nalog fiskalizovan — v. .PotrebnaIdentKupca).
// NE koristi ntechOtvoriModal ovde — ta funkcija snima početno stanje forme i
// auto-submituje na zatvaranje dialoga (za popup polja bez dugmeta Sačuvaj), što
// bi za storno značilo da se nalog stornira i pri slučajnom zatvaranju bez klika
// na „Potvrdi storno".
window.ntechPotvrdiPaOtvoriStorno = function (btn, poruka) {
window._ntechPotvrdi(poruka).then(function (ok) {
if (!ok) return;
var d = btn.nextElementSibling;
if (d && d.tagName === 'DIALOG') d.showModal();
});
};
var pib = document.getElementById('storno-pib');
var btnPotvrdi = document.getElementById('btn-storno-potvrdi');
if (pib && btnPotvrdi) {
pib.addEventListener('input', function () {
var ok = pib.value.trim() !== '';
btnPotvrdi.disabled = !ok;
btnPotvrdi.style.opacity = ok ? '1' : '0.4';
btnPotvrdi.style.cursor = ok ? 'pointer' : 'not-allowed';
});
}
})();
</script>
{{end}}
<!-- zona za brisanje -->