Files
GoNtech/web/static/js/ntech.js
T
Dasko 30db396ee6 feat(kalkulacija): Faza C — marža po kategoriji/artiklu + zavisni troškovi (backend)
Celina 1 (kompletna) — marža po kategoriji/artiklu:
- migracija 046: nullable marza na artikli i kategorije
- model Marza *float64 (Artikal, Kategorija) + KategorijaMarza u ArtikalSaKategorijom
- repo: čitanje/pisanje marže; nove DohvatiID/Izmeni za kategoriju
- dozvola kategorija.izmeni; handler IzmeniKategoriju + ruta
- UI: polje marže u formi artikla i kategorije; modal izmene kategorije
- nabavka: fallback predlog marže artikal → kategorija → globalna (izaberiArtikal)

Celina 2 (backend) — zavisni troškovi nabavke:
- migracija 047: tabela nabavka_troskovi + kolona metod_raspodele na nabavke
- model NabavkaTrosak, MetodRaspodele; čista funkcija RasporediTroskove + test
- repo: Kreiraj upisuje troškove i metod; DohvatiTroskove
- handler: parsiranje troškova/metoda; kalkulativna nabavna cena na serveru

UI forme troškova i prikaz u detaljima nabavke slede.
2026-06-14 16:12:03 +02:00

399 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// otvara/zatvara podmeni u sidebaru — radi i kad je sidebar skupljen i kad je proširen
// (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni.
function ntechTogglePodmeni(btn) {
var podmeni = btn.nextElementSibling;
if (!podmeni) return;
var jeOtvoren = podmeni.classList.contains('otvoren');
// zatvori sve podmenije i vrati njihove strelice — međusobna isključivost
document.querySelectorAll('#sidebar .nav-podmeni').forEach(function(el) {
el.classList.remove('otvoren');
var dugme = el.previousElementSibling;
if (dugme) {
var s = dugme.querySelector('.nav-strelica svg');
if (s) s.style.transform = 'rotate(0deg)';
}
});
// ako kliknuti nije već bio otvoren — otvori ga
if (!jeOtvoren) {
podmeni.classList.add('otvoren');
var svg = btn.querySelector('.nav-strelica svg');
if (svg) svg.style.transform = 'rotate(180deg)';
}
}
// registruje klik listenere na podmeni dugmad — sidebar se nikad ne menja, poziva se jednom
function ntechDodajPodmeniListenere() {
document.querySelectorAll('#sidebar [data-podmeni-dugme]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
ntechTogglePodmeni(btn);
});
});
}
// sprečava CSS tranziciju na podmeniima koji su već otvoreni pri inicijalnom učitavanju
// (bez ovoga, max-height animira od 0 do 300px pri svakom swap-u)
function ntechInicijalizujPodmeni() {
document.querySelectorAll('.nav-podmeni.otvoren').forEach(function(el) {
el.classList.add('bez-tranzicije');
requestAnimationFrame(function() {
requestAnimationFrame(function() {
el.classList.remove('bez-tranzicije');
});
});
});
}
document.addEventListener('alpine:init', () => {
// sidebar — podmeni "Moj profil"
Alpine.data('sidebarProfil', () => ({
otvoren: false,
init() {
this.otvoren = this.$el.dataset.otvoren === 'true'
}
}))
// sidebar — podmeni "Podešavanja"
Alpine.data('sidebarPodesavanja', () => ({
otvoren: false,
init() {
this.otvoren = this.$el.dataset.otvoren === 'true'
}
}))
// profil — odabir teme
Alpine.data('profilTemaOdabir', () => ({
tema: 'tamna',
init() {
this.tema = this.$el.dataset.tema || 'tamna'
}
}))
// preview login pozadine (podesavanja.html, podesavanja_izgled.html)
Alpine.data('loginPozadinaPreview', () => ({
pozadina: '',
blurPozadine: 0,
blurKartice: 12,
opacity: 50,
zatamnjenjeKartice: 0,
init() {
this.pozadina = this.$el.dataset.pozadina || ''
this.blurPozadine = parseInt(this.$el.dataset.blurPozadine) || 0
this.blurKartice = parseInt(this.$el.dataset.blurKartice) || 12
this.opacity = parseInt(this.$el.dataset.opacity) || 50
this.zatamnjenjeKartice = parseInt(this.$el.dataset.zatamnjenjeKartice) || 0
},
stilPozadine() {
const bgCss = this.pozadina ? "background:url('" + this.pozadina + "') center/cover;" : 'background:#1a2033;'
const inset = this.blurPozadine > 0 ? '-20px' : '0'
return 'position:absolute;inset:' + inset + ';' + bgCss + 'filter:blur(' + this.blurPozadine + 'px);z-index:0;'
},
stilOverlay() {
return 'position:absolute;inset:0;z-index:1;pointer-events:none;background:rgba(0,0,0,' + (this.opacity / 100) + ')'
},
stilKartice() {
return 'width:140px;border-radius:10px;background:rgba(0,0,0,' + (this.zatamnjenjeKartice / 100) + ');backdrop-filter:blur(' + this.blurKartice + 'px);-webkit-backdrop-filter:blur(' + this.blurKartice + 'px);border:1px solid rgba(255,255,255,0.18);box-shadow:0 8px 32px rgba(0,0,0,0.3);padding:14px 16px;'
}
}))
// preview lične pozadine (profil_tema.html)
Alpine.data('lokalnaPozadinaPreview', () => ({
pozadina: '',
blur: 12,
opacity: 50,
blurPozadine: 0,
glassOpacity: 10,
init() {
this.pozadina = this.$el.dataset.pozadina || ''
// ne koristimo „|| podrazumevano" jer je 0 validna vrednost a falsy — pala bi na podrazumevano
this.blur = this.broj(this.$el.dataset.blur, 12)
this.opacity = this.broj(this.$el.dataset.opacity, 50)
this.blurPozadine = this.broj(this.$el.dataset.blurPozadine, 0)
this.glassOpacity = this.broj(this.$el.dataset.glassOpacity, 10)
},
// vraća ceo broj iz vrednosti; ako nije broj, vraća podrazumevano (0 ostaje 0)
broj(vrednost, podrazumevano) {
const n = parseInt(vrednost, 10)
return Number.isNaN(n) ? podrazumevano : n
},
stilPozadine() {
const bgCss = this.pozadina ? "background:url('" + this.pozadina + "') center/cover;" : 'background:#1a2033;'
return 'position:absolute;inset:0;' + bgCss + 'z-index:0;filter:blur(' + this.blurPozadine + 'px);-webkit-filter:blur(' + this.blurPozadine + 'px);transform:scale(1.05);'
},
stilOverlay() {
return 'position:absolute;inset:0;z-index:1;pointer-events:none;background:rgba(0,0,0,' + (this.opacity / 100) + ')'
},
stilSidebar() {
return 'position:absolute;top:0;left:0;bottom:0;z-index:2;width:56px;background:rgba(0,0,0,' + (this.glassOpacity / 100) + ');backdrop-filter:blur(' + this.blur + 'px);-webkit-backdrop-filter:blur(' + this.blur + 'px);border-right:1px solid rgba(255,255,255,0.15);display:flex;flex-direction:column;align-items:center;padding-top:10px;gap:12px'
},
stilKartica() {
return 'height:36px;border-radius:6px;background:rgba(0,0,0,' + (this.glassOpacity / 100) + ');backdrop-filter:blur(' + this.blur + 'px);-webkit-backdrop-filter:blur(' + this.blur + 'px);border:1px solid rgba(255,255,255,0.15);display:flex;flex-direction:column;justify-content:center;padding:0 10px'
}
}))
// forma za prodaju
Alpine.data('prodajaForma', () => ({
stavke: [{artikal_id: '', kolicina: 1, cena: 0, pdv_stopa: 20}],
artikliOpcije: [],
isMobile: false,
init() {
this.artikliOpcije = window._ntechArtikli || []
this.isMobile = window.matchMedia('(max-width: 768px)').matches
window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
this.isMobile = e.matches
})
},
dodajStavku() {
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, pdv_stopa: 20})
},
ukloniStavku(i) {
if (this.stavke.length > 1) this.stavke.splice(i, 1)
},
popuniCenu(stavka) {
const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id)
if (a) {
stavka.cena = a.cena
stavka.pdv_stopa = a.pdv_stopa !== undefined ? a.pdv_stopa : 20
}
},
dostupnaKolicina(i) {
const stavka = this.stavke[i]
if (!stavka.artikal_id) return null
const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id)
if (!a) return null
const ostale = this.stavke.reduce((sum, s, j) =>
sum + (j !== i && s.artikal_id == stavka.artikal_id ? (parseInt(s.kolicina) || 0) : 0), 0)
return a.kolicina - ostale
},
prekoracenje(i) {
const d = this.dostupnaKolicina(i)
if (d === null) return false
return (parseInt(this.stavke[i].kolicina) || 0) > d
},
imaPrekoracenja() {
return this.stavke.some((_, i) => this.prekoracenje(i))
},
ukupnoStavke(s) {
return (parseFloat(s.kolicina) * parseFloat(s.cena) || 0).toFixed(2)
},
ukupnoSvega() {
return this.stavke.reduce((z, s) => z + (parseFloat(s.kolicina) * parseFloat(s.cena) || 0), 0).toFixed(2)
}
}))
// forma za nabavku
Alpine.data('nabavkaForma', () => ({
stavke: [{artikal_id: '', kolicina: 1, cena: 0, marza: 0, prodajna: 0}],
artikliOpcije: [],
marzaDefault: 0,
isMobile: false,
modal: false,
modalUcitavanje: false,
modalGreska: '',
modalNaziv: '',
modalKategorijaID: '',
modalOpis: '',
modalKolicina: '',
modalKolicinaMin: '',
modalCena: '',
modalLokacija: '',
modalNapomena: '',
modalDob: false,
modalDobUcitavanje: false,
modalDobGreska: '',
modalDobNaziv: '',
modalDobKontakt: '',
modalDobTelefon: '',
modalDobEmail: '',
modalDobPib: '',
modalDobMesto: '',
modalDobNapomena: '',
init() {
this.artikliOpcije = window._ntechArtikli || []
this.marzaDefault = parseFloat(window._ntechMarza) || 0
this.stavke.forEach(s => { s.marza = this.marzaDefault })
this.isMobile = window.matchMedia('(max-width: 768px)').matches
window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
this.isMobile = e.matches
})
},
dodajStavku() {
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, marza: this.marzaDefault, prodajna: 0})
},
// PDV stopa izabranog artikla (iz JSON liste) — za obračun prodajne cene
pdvStopa(artikalId) {
const a = this.artikliOpcije.find(x => String(x.id) === String(artikalId))
return a ? (parseFloat(a.pdv_stopa) || 0) : 0
},
// pri izboru artikla predloži maržu: artikal → kategorija → globalna, pa izračunaj prodajnu
izaberiArtikal(s) {
const a = this.artikliOpcije.find(x => String(x.id) === String(s.artikal_id))
if (a) {
if (a.marza != null) s.marza = a.marza
else if (a.kategorija_marza != null) s.marza = a.kategorija_marza
else s.marza = this.marzaDefault
}
this.izracunajProdajnu(s)
},
// prodajna (sa PDV) = nabavna × (1 + marža/100) × (1 + pdvStopa/100), zaokruženo na 2 decimale
izracunajProdajnu(s) {
const cena = parseFloat(s.cena) || 0
const marza = parseFloat(s.marza) || 0
const pdv = this.pdvStopa(s.artikal_id)
s.prodajna = Math.round(cena * (1 + marza / 100) * (1 + pdv / 100) * 100) / 100
},
ukloniStavku(i) {
if (this.stavke.length > 1) this.stavke.splice(i, 1)
},
ukupnoStavke(s) {
return (parseFloat(s.kolicina) * parseFloat(s.cena) || 0).toFixed(2)
},
ukupnoSvega() {
return this.stavke.reduce((z, s) => z + (parseFloat(s.kolicina) * parseFloat(s.cena) || 0), 0).toFixed(2)
},
otvoriModal() {
this.modal = true
this.modalGreska = ''
this.modalNaziv = ''
this.modalKategorijaID = ''
this.modalOpis = ''
this.modalKolicina = ''
this.modalKolicinaMin = ''
this.modalCena = ''
this.modalLokacija = ''
this.modalNapomena = ''
this.$nextTick(() => this.$refs.modalNazivInput && this.$refs.modalNazivInput.focus())
},
zatvoriModal() {
this.modal = false
},
async sacuvajArtikal() {
if (!this.modalNaziv.trim()) {
this.modalGreska = 'Naziv artikla je obavezan.'
return
}
this.modalUcitavanje = true
this.modalGreska = ''
const params = new URLSearchParams()
params.append('naziv', this.modalNaziv.trim())
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)
params.append('lokacija', this.modalLokacija.trim())
params.append('napomena', this.modalNapomena.trim())
params.append('_csrf', document.querySelector('meta[name=csrf-token]')?.content || '')
try {
const odgovor = await fetch('/magacin/novi', {
method: 'POST',
headers: {'X-Requested-With': 'fetch', 'Content-Type': 'application/x-www-form-urlencoded'},
body: params
})
if (!odgovor.ok) {
this.modalGreska = 'Greška pri čuvanju artikla. Pokušajte ponovo.'
return
}
const noviArtikal = await odgovor.json()
this.artikliOpcije.push({id: noviArtikal.id, naziv: noviArtikal.naziv})
this.zatvoriModal()
} catch {
this.modalGreska = 'Greška pri komunikaciji sa serverom.'
} finally {
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
}
}
}))
})
// za prvo učitavanje (defer script) — sprečava animaciju podmenia koji su inicijalno otvoreni
ntechInicijalizujPodmeni()
ntechDodajPodmeniListenere()
// klizač (input[type=range].klizac): popunjava plavom deo pre glave u Chromium-u
// preko CSS promenljive --popunjeno. Firefox to radi sam (::-moz-range-progress).
function ntechAzurirajKlizac(el) {
var min = parseFloat(el.min) || 0
var max = parseFloat(el.max)
if (isNaN(max)) max = 100
var v = parseFloat(el.value) || 0
var procenat = max > min ? ((v - min) / (max - min)) * 100 : 0
el.style.setProperty('--popunjeno', procenat + '%')
}
function ntechInicijalizujKlizace() {
document.querySelectorAll('input[type="range"].klizac').forEach(ntechAzurirajKlizac)
}
(function() {
if (window._ntechKlizacDodato) return
window._ntechKlizacDodato = true
// delegirani listener — hvata i klizače ubačene kasnije (HTMX swap)
document.addEventListener('input', function(e) {
var t = e.target
if (t && t.classList && t.classList.contains('klizac')) ntechAzurirajKlizac(t)
})
// početno popunjavanje: posle učitavanja, posle HTMX swap-a i kad Alpine postavi vrednosti
document.addEventListener('DOMContentLoaded', ntechInicijalizujKlizace)
document.addEventListener('htmx:afterSettle', ntechInicijalizujKlizace)
document.addEventListener('alpine:initialized', ntechInicijalizujKlizace)
})()