Prodaja: rešen hover na footer-u, auto-dodavanje nove stavke, X dugme za brisanje (CSP fix), naplati dugme

This commit is contained in:
2026-07-01 20:14:21 +02:00
parent d344d0b027
commit 1df83866df
2 changed files with 49 additions and 32 deletions
+30 -12
View File
@@ -137,6 +137,7 @@ document.addEventListener('alpine:init', () => {
// forma za prodaju // forma za prodaju
Alpine.data('prodajaForma', () => ({ Alpine.data('prodajaForma', () => ({
stavke: [{artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: 20, popust: 0, kategorija_naziv: '', _naziv: '', _pretraga: '', _prikaziListu: false, _limit: 30}], stavke: [{artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: 20, popust: 0, kategorija_naziv: '', _naziv: '', _pretraga: '', _prikaziListu: false, _limit: 30}],
zbirnoHover: false,
artikliOpcije: [], artikliOpcije: [],
pretragaArtikal: '', pretragaArtikal: '',
pdvObveznik: true, pdvObveznik: true,
@@ -257,10 +258,19 @@ document.addEventListener('alpine:init', () => {
poslednja._prikaziListu = true poslednja._prikaziListu = true
return return
} }
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: this.pdvObveznik ? 20 : 0, popust: 0, kategorija_naziv: '', _naziv: '', _pretraga: '', _prikaziListu: false, _limit: 30}) this.stavke.push(this.novaStavka())
},
novaStavka() {
return {artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: this.pdvObveznik ? 20 : 0, popust: 0, kategorija_naziv: '', _naziv: '', _pretraga: '', _prikaziListu: false, _limit: 30}
}, },
ukloniStavku(i) { ukloniStavku(i) {
if (this.stavke.length > 1) this.stavke.splice(i, 1) this.stavke.splice(i, 1)
// ne ostavljaj tabelu bez ijednog reda — korisnik uvek treba prazan red spreman za unos.
// $nextTick: ako se novi red doda u istom tiku kad je poslednji obrisan, Alpine ume da
// pogrešno recikliše DOM čvor istog x-for ključa (indeksa) i ostavi zastarele x-show/x-text.
if (this.stavke.length === 0) {
this.$nextTick(() => this.stavke.push(this.novaStavka()))
}
}, },
izaberiArtikal(stavka, a) { izaberiArtikal(stavka, a) {
stavka.artikal_id = a.id stavka.artikal_id = a.id
@@ -269,6 +279,11 @@ document.addEventListener('alpine:init', () => {
stavka._prikaziListu = false stavka._prikaziListu = false
stavka._limit = 30 stavka._limit = 30
this.popuniCenu(stavka) this.popuniCenu(stavka)
// ako je ovo bila poslednja (i sad popunjena) stavka, odmah dodaj sledeći prazan red
// da korisnik ne mora ručno da klikne "+ Dodaj stavku" pri unosu više artikala zaredom
if (this.stavke[this.stavke.length - 1] === stavka) {
this.stavke.push(this.novaStavka())
}
}, },
promeniArtikal(stavka) { promeniArtikal(stavka) {
stavka.artikal_id = '' stavka.artikal_id = ''
@@ -292,7 +307,7 @@ document.addEventListener('alpine:init', () => {
return return
} }
const prazna = this.stavke.find(s => !s.artikal_id) const prazna = this.stavke.find(s => !s.artikal_id)
const stavka = prazna || this.stavke[this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: this.pdvObveznik ? 20 : 0, popust: 0, kategorija_naziv: '', _naziv: '', _pretraga: '', _prikaziListu: false, _limit: 30}) - 1] const stavka = prazna || this.stavke[this.stavke.push(this.novaStavka()) - 1]
this.izaberiArtikal(stavka, a) this.izaberiArtikal(stavka, a)
}, },
popuniCenu(stavka) { popuniCenu(stavka) {
@@ -329,8 +344,18 @@ document.addEventListener('alpine:init', () => {
imaPrekoracenja() { imaPrekoracenja() {
return this.stavke.some((_, i) => this.prekoracenje(i)) return this.stavke.some((_, i) => this.prekoracenje(i))
}, },
imaPraznihStavki() { imaPopunjenuStavku() {
return this.stavke.some(s => !s.artikal_id) return this.stavke.some(s => s.artikal_id)
},
// stavkaImaVrednost — koristi se za prikaz dugmeta "Ukloni" (skriven na praznom redu bez vrednosti)
stavkaImaVrednost(stavka) {
return (Number(this.ukupnoStavke(stavka)) || 0) > 0
},
// posaljiProdaju uklanja prazne stavke (npr. automatski dodat red na kraju koji korisnik
// nije popunio) pre nego što se forma stvarno pošalje na server
posaljiProdaju(e) {
this.stavke = this.stavke.filter(s => s.artikal_id)
this.$nextTick(() => e.target.submit())
}, },
ukupnoStavke(s) { ukupnoStavke(s) {
const cena = parseFloat(s.cena) || 0 const cena = parseFloat(s.cena) || 0
@@ -346,13 +371,6 @@ document.addEventListener('alpine:init', () => {
return z + (parseFloat(s.kolicina) * cenaPosle || 0) return z + (parseFloat(s.kolicina) * cenaPosle || 0)
}, 0).toFixed(2) }, 0).toFixed(2)
}, },
// pdvIznosStavke vraća PDV sadržan u ceni po komadu (cena je bruto — PDV se izdvaja naniže)
pdvIznosStavke(s) {
const cena = parseFloat(s.cena) || 0
const stopa = parseFloat(s.pdv_stopa) || 0
if (!stopa) return 0
return cena - cena / (1 + stopa / 100)
},
osnovicaSvega() { osnovicaSvega() {
return this.stavke.reduce((z, s) => { return this.stavke.reduce((z, s) => {
const cena = parseFloat(s.cena) || 0 const cena = parseFloat(s.cena) || 0
+13 -14
View File
@@ -31,7 +31,7 @@
Nazad na prodaju Nazad na prodaju
</a> </a>
<form method="POST" action="/prodaja/nova"> <form method="POST" action="/prodaja/nova" @submit.prevent="posaljiProdaju($event)">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}" /> <input type="hidden" name="_csrf" value="{{.CsrfToken}}" />
{{if .Greska}} {{if .Greska}}
<div class="poruka-greska greska-animacija">{{.Greska}}</div> <div class="poruka-greska greska-animacija">{{.Greska}}</div>
@@ -104,7 +104,10 @@
</div> </div>
<!-- stavke --> <!-- stavke -->
<div class="kartica forma-kartica animiraj" style="margin-bottom: 16px"> <!-- :style ovde vraća karticu na bazno stanje dok je miš nad zbirnim redom (Osnovica/PDV/Ukupno) —
inline stil pobeđuje .kartica:hover, jer čist CSS ne ume selektivno da isključi hover na detetu -->
<div class="kartica forma-kartica animiraj"
:style="'margin-bottom: 16px;' + (zbirnoHover ? 'box-shadow: var(--senka); border-color: var(--ivica); background: var(--kartica); transform: none;' : '')">
<div style="margin-bottom: 16px; padding-bottom: 14px; border-bottom: 0.5px solid var(--ivica); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;"> <div style="margin-bottom: 16px; padding-bottom: 14px; border-bottom: 0.5px solid var(--ivica); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;">
<span style="font-size: 16px; font-weight: 500; color: var(--tekst-glavni)">Stavke</span> <span style="font-size: 16px; font-weight: 500; color: var(--tekst-glavni)">Stavke</span>
<button type="button" @click="dodajStavku()" class="btn-primarno" style="font-size: 13px; padding: 6px 14px"> <button type="button" @click="dodajStavku()" class="btn-primarno" style="font-size: 13px; padding: 6px 14px">
@@ -174,10 +177,7 @@
<div x-show="prekoracenje(i)" style="font-size: 11px; color: #dc2626; margin-top: 1px; text-align: center; white-space: nowrap;">Prekoračena količina</div> <div x-show="prekoracenje(i)" style="font-size: 11px; color: #dc2626; margin-top: 1px; text-align: center; white-space: nowrap;">Prekoračena količina</div>
</td> </td>
<td style="padding: 8px 10px"> <td style="padding: 8px 10px">
<div style="display:flex;flex-direction:column;gap:2px;align-items:flex-end;">
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" :disabled="isMobile" style="width: 100%; text-align: right" /> <input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" :disabled="isMobile" style="width: 100%; text-align: right" />
<span x-show="pdvObveznik && stavka.pdv_stopa > 0" style="font-size:10px;color:var(--tekst-sporedni);" x-text="'PDV ' + stavka.pdv_stopa + '% uključen'"></span>
</div>
</td> </td>
<td style="padding: 8px 10px"> <td style="padding: 8px 10px">
<input type="number" :name="'popust[]'" x-model="stavka.popust" min="0" max="100" step="0.01" :disabled="isMobile" style="width: 100%; text-align: right" /> <input type="number" :name="'popust[]'" x-model="stavka.popust" min="0" max="100" step="0.01" :disabled="isMobile" style="width: 100%; text-align: right" />
@@ -186,14 +186,14 @@
<span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span> <span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
</td> </td>
<td style="padding: 8px 10px; text-align: center"> <td style="padding: 8px 10px; text-align: center">
<button type="button" @click="ukloniStavku(i)" x-show="stavke.length > 1" <button type="button" @click="ukloniStavku(i)" x-show="stavkaImaVrednost(stavka)"
style="background: none; border: none; cursor: pointer; color: #dc2626; font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 4px; transition: background 0.15s;" style="background: none; border: none; cursor: pointer; color: #dc2626; font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 4px; transition: background 0.15s;"
onmouseover="this.style.background = 'rgba(220,38,38,0.08)'" onmouseout="this.style.background = 'none'" title="Ukloni stavku">×</button> onmouseover="this.style.background = 'rgba(220,38,38,0.08)'" onmouseout="this.style.background = 'none'" title="Ukloni stavku">×</button>
</td> </td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
<tfoot> <tfoot @mouseenter="zbirnoHover = true" @mouseleave="zbirnoHover = false">
<tr x-show="pdvObveznik" x-cloak style="border-top: 0.5px solid var(--ivica)"> <tr x-show="pdvObveznik" x-cloak style="border-top: 0.5px solid var(--ivica)">
<td colspan="4" style="padding: 4px 10px; text-align: right; font-size: 12px; color: var(--tekst-sporedni);">Osnovica:</td> <td colspan="4" style="padding: 4px 10px; text-align: right; font-size: 12px; color: var(--tekst-sporedni);">Osnovica:</td>
<td style="padding: 4px 10px; text-align: right; font-size: 12px; color: var(--tekst-sporedni);"> <td style="padding: 4px 10px; text-align: right; font-size: 12px; color: var(--tekst-sporedni);">
@@ -221,18 +221,15 @@
<!-- mobilne kartice stavki --> <!-- mobilne kartice stavki -->
<div class="stavke-kartice" style="display: none; flex-direction: column; gap: 10px"> <div class="stavke-kartice" style="display: none; flex-direction: column; gap: 10px">
<!-- pretraga na mobilnom -->
<input type="text" x-model="pretragaArtikal" @keydown.enter.prevent="dodajPoBarkodu()" placeholder="Pretraži artikle ili skeniraj barkod..." autocomplete="off" style="width:100%;">
<template x-for="(stavka, i) in stavke" :key="i"> <template x-for="(stavka, i) in stavke" :key="i">
<div style="border: 0.5px solid var(--ivica); border-radius: 8px; padding: 12px;"> <div style="border: 0.5px solid var(--ivica); border-radius: 8px; padding: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-size: 13px; font-weight: 500; color: var(--tekst-sporedni);" x-text="'Stavka ' + (i + 1)"></span> <span style="font-size: 13px; font-weight: 500; color: var(--tekst-sporedni);" x-text="'Stavka ' + (i + 1)"></span>
<button type="button" @click="ukloniStavku(i)" x-show="stavke.length > 1" <button type="button" @click="ukloniStavku(i)" x-show="stavkaImaVrednost(stavka)"
style="background: none; border: 0.5px solid #dc2626; color: #dc2626; cursor: pointer; font-size: 13px; padding: 2px 8px; border-radius: 4px;">Ukloni</button> style="background: none; border: 0.5px solid #dc2626; color: #dc2626; cursor: pointer; font-size: 13px; padding: 2px 8px; border-radius: 4px;">Ukloni</button>
</div> </div>
<div style="display: flex; flex-direction: column; gap: 10px"> <div style="display: flex; flex-direction: column; gap: 10px">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa"> <input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa">
<div x-show="stavka.kategorija_naziv" style="font-size:11px;color:var(--tekst-sporedni);" x-text="stavka.kategorija_naziv"></div>
<div style="position: relative;"> <div style="position: relative;">
<label class="polje-labela">Artikal</label> <label class="polje-labela">Artikal</label>
<input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="!isMobile"> <input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="!isMobile">
@@ -264,6 +261,7 @@
</div> </div>
</template> </template>
</div> </div>
<div x-show="stavka.kategorija_naziv" style="font-size:11px;color:var(--tekst-sporedni);margin-top:-4px;" x-text="stavka.kategorija_naziv"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div> <div>
<label class="polje-labela">Količina</label> <label class="polje-labela">Količina</label>
@@ -276,7 +274,6 @@
<div> <div>
<label class="polje-labela">Cena/kom (din){{if index .Moduli "pdv"}} sa PDV{{end}}</label> <label class="polje-labela">Cena/kom (din){{if index .Moduli "pdv"}} sa PDV{{end}}</label>
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" :disabled="!isMobile" style="width: 100%" /> <input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" :disabled="!isMobile" style="width: 100%" />
<div x-show="pdvObveznik && stavka.pdv_stopa > 0" style="font-size:10px;color:var(--tekst-sporedni);margin-top:3px;" x-text="'PDV ' + stavka.pdv_stopa + '% uključen'"></div>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
@@ -291,6 +288,7 @@
</div> </div>
</div> </div>
</template> </template>
<div @mouseenter="zbirnoHover = true" @mouseleave="zbirnoHover = false">
<div x-show="pdvObveznik" x-cloak style="text-align: right; font-size: 12px; color: var(--tekst-sporedni); padding: 0 4px;"> <div x-show="pdvObveznik" x-cloak style="text-align: right; font-size: 12px; color: var(--tekst-sporedni); padding: 0 4px;">
Osnovica: <span x-text="fmtDin(osnovicaSvega()) + ' din'"></span> · PDV: <span x-text="fmtDin(pdvSvega()) + ' din'"></span> Osnovica: <span x-text="fmtDin(osnovicaSvega()) + ' din'"></span> · PDV: <span x-text="fmtDin(pdvSvega()) + ' din'"></span>
</div> </div>
@@ -299,12 +297,13 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- dugmad forme --> <!-- dugmad forme -->
<div style="display: flex; justify-content: flex-end; gap: 10px"> <div style="display: flex; justify-content: flex-end; gap: 10px">
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja" class="btn-sekundarno">Odustani</a> <a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja" class="btn-sekundarno">Odustani</a>
<button type="submit" class="btn-primarno" :disabled="imaPrekoracenja() || imaPraznihStavki()" :style="(imaPrekoracenja() || imaPraznihStavki()) ? 'opacity:0.4;cursor:not-allowed;' : ''"> <button type="submit" class="btn-primarno" :disabled="imaPrekoracenja() || !imaPopunjenuStavku()" :style="(imaPrekoracenja() || !imaPopunjenuStavku()) ? 'opacity:0.4;cursor:not-allowed;' : ''">
Sačuvaj prodaju Naplati
</button> </button>
</div> </div>
</form> </form>