Storno kroz modal + ispravka about:blank u fiskalnom tabu Prodaje

- Servis i Prodaja: storniranje naloga sad ide kroz modal — dugme
  'Storniraj nalog' prvo traži potvrdu, tek onda otvara modal sa poljem za
  PIB/JMBG (kad je potrebno za refund); dugme 'Potvrdi storno' ostaje
  zaključano dok se polje ne popuni. Posle uspešnog storna modal se gasi i
  sadržaj stranice se osvežava (data-refresh=#sadrzaj).
- Dnevni pazar (Fiskalni presek): link u sidebaru i sve tri rute
  (FiskalniPazar, ZakljuciFiskalniDan, FiskalniIzvestaj) sad proveravaju
  modul fiskalizacije, ne samo RBAC dozvolu i tehničku dostupnost PFR-a.
- Prodaja: čekboks 'Prikaži fiskalni račun' se više ne nudi kad firma ne
  fiskalizuje, a pripremiFiskalniTab() više ne otvara prazan tab u tom
  slučaju — do sad je ostajao trajno na about:blank jer odredišna stranica
  nikad nije imala fiskalni račun da ga popuni. Dodata i odbrana za slučaj
  kad fiskalizacija jeste uključena ali PFR nije dostupan (zatvara osiroteli
  tab umesto about:blank).

Detalji u docs/Greške.md (van git-a).
This commit is contained in:
2026-07-03 02:22:13 +02:00
parent c87dc67857
commit edba23ae50
6 changed files with 157 additions and 25 deletions
+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")}}
+63 -11
View File
@@ -252,18 +252,58 @@
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}}
@@ -296,6 +336,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 -->