feat(nabavka): brzi unos dobavljača i artikla iz forme nabavke

Dugmad otvaraju modale koji preko AJAX-a (X-Requested-With: fetch) čuvaju
novi zapis i vraćaju JSON; novi dobavljač se odmah ubacuje u listu i bira.
Modal artikla proširen na sva polja kao puna stranica. Centriranje modala
prebačeno u .modal-overlay klasu (x-show je brisao inline display:flex).
This commit is contained in:
2026-06-14 08:16:51 +02:00
parent 7fc2e9bcc3
commit dfad0ff1f4
3 changed files with 215 additions and 5 deletions
+18 -1
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
@@ -85,7 +86,15 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) {
} }
dobavljac, greska := parseFormuDobavljaca(r) dobavljac, greska := parseFormuDobavljaca(r)
jeAjax := r.Header.Get("X-Requested-With") == "fetch"
if greska != "" { if greska != "" {
// fetch zahtev (iz modala u nabavci) dobija JSON grešku
if jeAjax {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"greska":%q}`, greska)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "dobavljaci" ps.Stranica = "dobavljaci"
@@ -99,11 +108,19 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, err := h.DobavljaciRepo.Kreiraj(r.Context(), &dobavljac); err != nil { id, err := h.DobavljaciRepo.Kreiraj(r.Context(), &dobavljac)
if err != nil {
http.Error(w, "Greška pri čuvanju dobavljača", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju dobavljača", http.StatusInternalServerError)
return return
} }
// fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog dobavljača
if jeAjax {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"id":%d,"naziv":%q}`, id, dobavljac.Naziv)
return
}
http.Redirect(w, r, "/dobavljaci?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/dobavljaci?sacuvano=1", http.StatusSeeOther)
} }
+81
View File
@@ -194,7 +194,22 @@ document.addEventListener('alpine:init', () => {
modalGreska: '', modalGreska: '',
modalNaziv: '', modalNaziv: '',
modalKategorijaID: '', modalKategorijaID: '',
modalOpis: '',
modalKolicina: '',
modalKolicinaMin: '',
modalCena: '', modalCena: '',
modalLokacija: '',
modalNapomena: '',
modalDob: false,
modalDobUcitavanje: false,
modalDobGreska: '',
modalDobNaziv: '',
modalDobKontakt: '',
modalDobTelefon: '',
modalDobEmail: '',
modalDobPib: '',
modalDobMesto: '',
modalDobNapomena: '',
init() { init() {
this.artikliOpcije = window._ntechArtikli || [] this.artikliOpcije = window._ntechArtikli || []
this.isMobile = window.matchMedia('(max-width: 768px)').matches this.isMobile = window.matchMedia('(max-width: 768px)').matches
@@ -219,7 +234,12 @@ document.addEventListener('alpine:init', () => {
this.modalGreska = '' this.modalGreska = ''
this.modalNaziv = '' this.modalNaziv = ''
this.modalKategorijaID = '' this.modalKategorijaID = ''
this.modalOpis = ''
this.modalKolicina = ''
this.modalKolicinaMin = ''
this.modalCena = '' this.modalCena = ''
this.modalLokacija = ''
this.modalNapomena = ''
this.$nextTick(() => this.$refs.modalNazivInput && this.$refs.modalNazivInput.focus()) this.$nextTick(() => this.$refs.modalNazivInput && this.$refs.modalNazivInput.focus())
}, },
zatvoriModal() { zatvoriModal() {
@@ -235,7 +255,12 @@ document.addEventListener('alpine:init', () => {
const params = new URLSearchParams() const params = new URLSearchParams()
params.append('naziv', this.modalNaziv.trim()) params.append('naziv', this.modalNaziv.trim())
if (this.modalKategorijaID) params.append('kategorija_id', this.modalKategorijaID) if (this.modalKategorijaID) params.append('kategorija_id', this.modalKategorijaID)
params.append('opis', this.modalOpis.trim())
if (this.modalKolicina) params.append('kolicina', this.modalKolicina)
if (this.modalKolicinaMin) params.append('kolicina_min', this.modalKolicinaMin)
if (this.modalCena) params.append('prodajna_cena', this.modalCena) if (this.modalCena) params.append('prodajna_cena', this.modalCena)
params.append('lokacija', this.modalLokacija.trim())
params.append('napomena', this.modalNapomena.trim())
params.append('_csrf', document.querySelector('meta[name=csrf-token]')?.content || '') params.append('_csrf', document.querySelector('meta[name=csrf-token]')?.content || '')
try { try {
const odgovor = await fetch('/magacin/novi', { const odgovor = await fetch('/magacin/novi', {
@@ -255,6 +280,62 @@ document.addEventListener('alpine:init', () => {
} finally { } finally {
this.modalUcitavanje = false this.modalUcitavanje = false
} }
},
otvoriModalDobavljac() {
this.modalDob = true
this.modalDobGreska = ''
this.modalDobNaziv = ''
this.modalDobKontakt = ''
this.modalDobTelefon = ''
this.modalDobEmail = ''
this.modalDobPib = ''
this.modalDobMesto = ''
this.modalDobNapomena = ''
this.$nextTick(() => this.$refs.modalDobNazivInput && this.$refs.modalDobNazivInput.focus())
},
zatvoriModalDobavljac() {
this.modalDob = false
},
async sacuvajDobavljaca() {
if (!this.modalDobNaziv.trim()) {
this.modalDobGreska = 'Naziv dobavljača je obavezan.'
return
}
this.modalDobUcitavanje = true
this.modalDobGreska = ''
const params = new URLSearchParams()
params.append('naziv', this.modalDobNaziv.trim())
params.append('kontakt_osoba', this.modalDobKontakt.trim())
params.append('telefon', this.modalDobTelefon.trim())
params.append('email', this.modalDobEmail.trim())
params.append('pib', this.modalDobPib.trim())
params.append('mesto', this.modalDobMesto.trim())
params.append('napomena', this.modalDobNapomena.trim())
params.append('_csrf', document.querySelector('meta[name=csrf-token]')?.content || '')
try {
const odgovor = await fetch('/dobavljaci/novi', {
method: 'POST',
headers: {'X-Requested-With': 'fetch', 'Content-Type': 'application/x-www-form-urlencoded'},
body: params
})
if (!odgovor.ok) {
const greska = await odgovor.json().catch(() => null)
this.modalDobGreska = (greska && greska.greska) || 'Greška pri čuvanju dobavljača. Pokušajte ponovo.'
return
}
const novi = await odgovor.json()
// dodaj novog dobavljača u padajuću listu i odmah ga izaberi
const opcija = document.createElement('option')
opcija.value = novi.id
opcija.textContent = novi.naziv
this.$refs.selDobavljac.appendChild(opcija)
this.$refs.selDobavljac.value = novi.id
this.zatvoriModalDobavljac()
} catch {
this.modalDobGreska = 'Greška pri komunikaciji sa serverom.'
} finally {
this.modalDobUcitavanje = false
}
} }
})) }))
+116 -4
View File
@@ -8,6 +8,8 @@
.forma-kartica:nth-child(1) { animation-delay: 0.04s; } .forma-kartica:nth-child(1) { animation-delay: 0.04s; }
.forma-kartica:nth-child(2) { animation-delay: 0.12s; } .forma-kartica:nth-child(2) { animation-delay: 0.12s; }
.modal-sadrzaj { animation: modalIn 0.25s ease forwards; } .modal-sadrzaj { animation: modalIn 0.25s ease forwards; }
/* centriranje preko klase (ne inline) — x-show menja inline display, pa bi obrisao flex i modal bi pao u ćošak */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; }
</style> </style>
{{end}} {{end}}
@@ -37,8 +39,15 @@
</div> </div>
<div class="kolona" style="gap:14px;"> <div class="kolona" style="gap:14px;">
<div> <div>
<label class="polje-labela">Dobavljač</label> <div style="display:flex;justify-content:space-between;align-items:center;">
<select name="dobavljac_id" style="width:100%;"> <label class="polje-labela">Dobavljač</label>
<button type="button" @click="otvoriModalDobavljac()"
style="padding:6px 14px;background:var(--kartica);color:var(--tekst-sporedni);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;cursor:pointer;margin-bottom:6px;transition:background 0.2s;"
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
+ Novi dobavljač
</button>
</div>
<select name="dobavljac_id" x-ref="selDobavljac" style="width:100%;">
<option value="">— bez dobavljača —</option> <option value="">— bez dobavljača —</option>
{{range .Dobavljaci}} {{range .Dobavljaci}}
<option value="{{.ID}}">{{.Naziv}}</option> <option value="{{.ID}}">{{.Naziv}}</option>
@@ -187,10 +196,10 @@
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;" class="modal-overlay"
@click.self="zatvoriModal()" @keydown.escape.window="zatvoriModal()"> @click.self="zatvoriModal()" @keydown.escape.window="zatvoriModal()">
<div class="kartica modal-sadrzaj" style="width:100%;max-width:440px;padding:24px;"> <div class="kartica modal-sadrzaj" style="width:100%;max-width:480px;padding:24px;max-height:90vh;overflow-y:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Novi artikal</span> <span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Novi artikal</span>
@@ -221,12 +230,44 @@
</select> </select>
</div> </div>
<div>
<label class="polje-labela">Opis</label>
<textarea x-model="modalOpis" rows="2"
placeholder="Kratak opis artikla..."
style="width:100%;resize:vertical;"></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">Količina na stanju</label>
<input type="number" x-model="modalKolicina" min="0" placeholder="0" style="width:100%;">
</div>
<div>
<label class="polje-labela">Minimalna količina</label>
<input type="number" x-model="modalKolicinaMin" min="0" placeholder="0" style="width:100%;">
</div>
</div>
<div> <div>
<label class="polje-labela">Prodajna cena (din)</label> <label class="polje-labela">Prodajna cena (din)</label>
<input type="number" x-model="modalCena" min="0" step="0.01" <input type="number" x-model="modalCena" min="0" step="0.01"
placeholder="0" placeholder="0"
style="width:100%;"> style="width:100%;">
</div> </div>
<div>
<label class="polje-labela">Lokacija u magacinu</label>
<input type="text" x-model="modalLokacija"
placeholder="npr. Regal A3, polica 2"
style="width:100%;">
</div>
<div>
<label class="polje-labela">Napomena</label>
<textarea x-model="modalNapomena" rows="2"
placeholder="Interna napomena o artiklu..."
style="width:100%;resize:vertical;"></textarea>
</div>
</div> </div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;padding-top:14px;border-top:0.5px solid var(--ivica);"> <div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;padding-top:14px;border-top:0.5px solid var(--ivica);">
@@ -240,5 +281,76 @@
</div> </div>
</div> </div>
<!-- modal: novi dobavljač -->
<div x-show="modalDob" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="modal-overlay"
@click.self="zatvoriModalDobavljac()" @keydown.escape.window="zatvoriModalDobavljac()">
<div class="kartica modal-sadrzaj" style="width:100%;max-width:480px;padding:24px;max-height:90vh;overflow-y:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Novi dobavljač</span>
<button type="button" @click="zatvoriModalDobavljac()" aria-label="Zatvori"
style="background:none;border:none;cursor:pointer;color:var(--tekst-sporedni);font-size:20px;line-height:1;padding:2px 6px;border-radius:4px;transition:background 0.15s;">×</button>
</div>
<div x-show="modalDobGreska" class="poruka-greska greska-animacija" x-text="modalDobGreska"></div>
<div class="kolona" style="gap:14px;">
<div>
<label class="polje-labela">Naziv <span style="color:#dc2626;">*</span></label>
<input type="text" x-model="modalDobNaziv" x-ref="modalDobNazivInput"
placeholder="npr. TechDistrib d.o.o." style="width:100%;"
@keydown.enter.prevent="sacuvajDobavljaca()">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">Kontakt osoba</label>
<input type="text" x-model="modalDobKontakt" placeholder="npr. Marko Petrović" style="width:100%;">
</div>
<div>
<label class="polje-labela">Telefon</label>
<input type="text" x-model="modalDobTelefon" placeholder="npr. 011 123 4567" style="width:100%;">
</div>
</div>
<div>
<label class="polje-labela">E-pošta</label>
<input type="text" x-model="modalDobEmail" placeholder="npr. nabavka@techdistrib.rs" style="width:100%;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">PIB</label>
<input type="text" x-model="modalDobPib" placeholder="npr. 123456789" style="width:100%;">
</div>
<div>
<label class="polje-labela">Mesto / grad</label>
<input type="text" x-model="modalDobMesto" placeholder="npr. Beograd" style="width:100%;">
</div>
</div>
<div>
<label class="polje-labela">Napomena</label>
<textarea x-model="modalDobNapomena" rows="2"
placeholder="Interna napomena o dobavljaču..."
style="width:100%;resize:vertical;"></textarea>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;padding-top:14px;border-top:0.5px solid var(--ivica);">
<button type="button" @click="zatvoriModalDobavljac()" class="btn-sekundarno">Odustani</button>
<button type="button" @click="sacuvajDobavljaca()" :disabled="modalDobUcitavanje" class="btn-primarno"
:style="modalDobUcitavanje ? 'opacity:0.6;cursor:not-allowed' : ''">
<span x-text="modalDobUcitavanje ? 'Čuvanje...' : 'Dodaj dobavljača'"></span>
</button>
</div>
</div>
</div>
</div> </div>
{{end}} {{end}}