Compare commits

...

27 Commits

Author SHA1 Message Date
Dasko 7eb472b9e6 Kartica artikla: prikaz vezanih dobavljača sa dodavanjem/uklanjanjem; helper telefon (064/123-4567); CLAUDE.md frontend i formatiranje sekcije 2026-06-21 01:34:15 +02:00
Dasko ac6deeeba4 Dinari: formatiranje preostalih iznosa (PDV KIR/KPR/obračun, nivelacije, stanje zaliha) 2026-06-21 01:08:58 +02:00
Dasko aabe19639a Dinari: formatiranje iznosa u Alpine tabelama stavki (nova nabavka i prodaja) preko fmtDin 2026-06-21 01:05:45 +02:00
Dasko f4a9c1eefe Dinari: formatiranje iznosa sa separatorom hiljada (helper dinari/dinariCeli); nabavka po dobavljaču (auto-veza, filter artikala, izbor dobavljača u formi artikla); UI doterivanja stavki nabavke 2026-06-21 01:00:56 +02:00
Dasko 91998a7736 Nabavka: predlog cene pri izboru artikla; forma artikla: dvosmerno marža↔prodajna cena 2026-06-21 00:01:09 +02:00
Dasko 32f2235127 Liste: interaktivna pretraga za dobavljače; poruka 'za datu pretragu nema rezultata' kad pretraga ne vraća ništa 2026-06-20 23:49:42 +02:00
Dasko 2727b0da80 Klijenti i Magacin: ukloni dugme Traži; pretraga poštuje filtere (tip/kategorija); gašenje animacije pri pretrazi u klijentima 2026-06-20 23:00:27 +02:00
Dasko b4d15f2df2 Klijenti: smanji prikaz na 50 po stranici 2026-06-20 22:52:43 +02:00
Dasko 830c51e95e Klijenti: filter po tipu (Svi/Firme/Fizička lica) preko radio dugmadi 2026-06-20 22:49:56 +02:00
Dasko 4caadd2ef0 Magacin: ukloni bljesak animacije pri sidebar navigaciji (reflow na afterSwap) 2026-06-20 22:37:51 +02:00
Dasko fec84f98d5 Ispravke: QR proxy šema, race šifra, JM validacija, zaštita zaliha, magacin history flash
- servis.go: qrNalogURL helper čita X-Forwarded-Proto za ispravan HTTPS QR kod iza proxy-ja
- magacin_forma.go: šifra se generiše pre INSERT (uklanja race condition); normalizujJM validacija 4 kar.; blokada promene tipa ako postoji stanje na lageru
- prodaja.go + repository.go: Obrisi beleži magacinsku promenu (PromenaPovracaj) uz korisnikID; ispravljeni zamenjeni potpisi interfejsa ServisRepository/ProdajaRepository
- kategorije.html: UI hint kada kategorija nema kôd (prefiks šifre)
- 061_backfill_kategorija_kod.sql: popunjava kod postojećim kategorijama iz naziva
- magacin.html: htmx:beforeHistorySave sklanja bez-anim pre snimanja snapshota (fix flash animacije)
2026-06-20 21:43:34 +02:00
Dasko b0250b2917 Artikli: šifre, tip i jedinica mere; magacin UI; servis predračun
Šifre artikala:
- Kôd kategorije kao prefiks auto-šifre (PREFIKS-NNNN), otporno na brisanje (max+1)
- Tip artikla (proizvod/usluga/trošak) i jedinica mere
- Arhiviranje artikala umesto brisanja kad su već u prometu

Magacin:
- Paginacija 50 po stranici
- Klikabilna šifra (vodi na karticu), opisniji placeholder pretrage
- Ispravka: pretraga više ne okida animaciju redova (globalni htmx listener
  umesto hx-on atributa koji se ne okida u ovoj htmx verziji)
- Dugmad akcija ne prelamaju tekst; uklonjen content-visibility (secanje pri skrolu)

Servis: predračun (nova stranica i ruta)
2026-06-20 18:40:01 +02:00
Dasko a8f368ca06 Paginacija, interaktivna pretraga i optimizacija prikaza
- Dodata server-side paginacija za magacin (127 artikala) i klijente (1040)
  — Limit/Offset u ArtikalFilter i KlijentFilter, 100 po stranici
  — PrebrojiPoFilteru za izračunavanje ukupnog broja stranica
- Interaktivna pretraga (search-as-you-type) sa HTMX:
  — hx-trigger="keyup changed delay:300ms" na polju pretrage
  — HTMX menja samo #magacin-rezultati / #klijenti-rezultati
  — Polje pretrage ostaje u fokusu tokom osvežavanja
- Popravljena pretraga klijenata po imenu i prezimenu:
  — Dodato (ime || ' ' || prezime) LIKE u sva tri upita
  — "Ivana Lazić" sada pronalazi klijenta
- CSS optimizacije za velike liste:
  — content-visibility: auto na redovima tabela i karticama
  — contain-intrinsic-size za stabilan scroll
  — animation-delay produžen do 20. reda / 10. kartice
2026-06-20 16:19:42 +02:00
Dasko 064d6dfa2a Bezbednost: ntechToast koristi textContent za tekst poruke (XSS zaštita)
CodeQL js/xss-through-dom — flash poruku server HTML-escape-uje, ali textContent
je dekodira, pa bi innerHTML ponovo interpretirao sadržaj kao HTML. Sada tekst
ide kroz textContent, a samo statički SVG kroz innerHTML.
2026-06-20 14:12:13 +02:00
Dasko 1068bb12e0 Bezbednost: limit veličine upload tela, anti-enumeracija pri prijavi, strings.Cut
- CSRF middleware postavlja MaxBytesReader (6 MB) za multipart pre parsiranja —
  pojedinačni upload handleri nisu mogli da ograniče veličinu jer čitanje _csrf
  polja već parsira celo telo
- prijava: dummy bcrypt poređenje kada korisnik ne postoji, da vreme odgovora
  bude isto kao kod postojećeg korisnika (sprečava enumeraciju imena)
- podesavanja: strings.Split(...)[0] zamenjen sa strings.Cut
2026-06-20 14:07:38 +02:00
Dasko 8c0e9d50a0 Formatiranje: poravnanje whitelist mape u podesavanja.go 2026-06-20 14:03:07 +02:00
Dasko 6f0ad3f29c Logo u topbaru: svič ga pokazuje/skriva odmah bez reloda cele strane 2026-06-20 13:57:22 +02:00
Dasko 070f9384cf Logo: otpremanje i greške vode na /admin/podesavanja/opste umesto stare stranice 2026-06-20 13:52:56 +02:00
Dasko fa717208c5 Ispravka: data-full-reload koristiti hasAttribute umesto dataset (prazan atribut je falsy) 2026-06-20 13:45:37 +02:00
Dasko 8855b5b84f Podešavanja Opšte: reload stranice posle čuvanja da se odmah vidi logo i naziv u topbaru 2026-06-20 13:42:15 +02:00
Dasko 45e4863ebb Podešavanja: Sistem, Servis i Kalkulacije ostaju na istoj stranici posle čuvanja 2026-06-20 13:36:31 +02:00
Dasko 8e1cf67618 Tema: pozadina se primenjuje na stranicu tek posle čuvanja, preview ostaje izolovan 2026-06-20 13:30:08 +02:00
Dasko fd35408da7 Tema: slajderi za pozadinu primenjuju se odmah bez refresha
CSS custom properties umesto hardkodovanih vrednosti u <style> bloku;
Alpine  ažurira --app-blur, --app-overlay, --app-glass-* direktno na :root
2026-06-20 13:27:20 +02:00
Dasko 755f56f87a Tema: preview animacije se ponavlja odmah pri promeni brzine slajdera 2026-06-20 13:22:19 +02:00
Dasko 4047f035da Admin: ispravke AJAX toast-a za sve akcije korisnika i dozvola
- base.html: toast pri učitavanju stranice ako URL sadrži ?sacuvano=1 (pokriven i cross-page redirect)
- admin.go: svih 9 SetFlash(uspeh) zamenjeno sa redirect ?sacuvano=1 (korisnici, profil, dozvole)
2026-06-20 13:15:54 +02:00
Dasko 2937acfcc1 Servis: javni status nalog + ispravke AJAX čuvanja
- Dodat javni token na servisni nalog (migracija 057), QR kod vodi na /status/{token}
- Nova javna stranica /status/{token} — bez prijave, za klijente
- Sve forme sa "Sačuvaj izmene" koriste ?sacuvano=1 umesto SetFlash za uspeh
- AJAX logika: toast + ostanak samo kad pathname ostaje isti; inače navigacija
- Ispravke: PDV stope, KIR, KPR, podešavanja izgled, storno prodaje, nivelacija, delovi naloga
2026-06-20 13:04:23 +02:00
Dasko f7a5d2673b Tema: slider za brzinu animacije, zamena scaleIn sa blurIn, AJAX čuvanje
- nova animacija blurIn (zamagljivanje) umesto scaleIn koji je izgledao isto kao fadeIn
- slider za brzinu animacije (0.1s–0.8s, korak 0.1) premešten u karticu animacije
- brzina i vrsta animacije čuvaju se jednim klikom, iz istog forma
- nova kolona lokalna_brzina_animacije u bazi (migracija 056)
- AJAX čuvanje profil/tema: nema reload stranice, scroll ostaje, toast notifikacija
- otpremnica vidljiva samo za status Završeno/Preuzeto; radni nalog skriven kada završeno
- toast notifikacije sa punom bojom pozadine (svetla i tamna tema)
2026-06-20 12:42:11 +02:00
71 changed files with 2721 additions and 480 deletions
+14 -7
View File
@@ -239,6 +239,7 @@ func main() {
r.Get("/setup", h.PrikazSetupa)
r.Post("/setup", h.SacuvajSetup)
r.Get("/odjava", h.Odjava)
r.Get("/status/{token}", h.ServisJavniStatus)
// zaštićene rute — zahtevaju prijavljenog korisnika
r.Group(func(r chi.Router) {
@@ -300,12 +301,16 @@ func main() {
r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr)
r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica)
r.Get("/magacin", h.Magacin)
r.Get("/magacin/kartica/{id}", h.MagacinskaKartica)
r.Get("/magacin/kartica/{id}", h.MagacinskaKartica)
r.Get("/magacin/novi", h.NoviArtikal)
r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal)
r.Get("/magacin/sledeca-sifra", h.PredlogSifre)
r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla)
r.With(doz("artikal.obrisi")).Get("/magacin/obrisi/{id}", h.ObrisiArtikal)
r.With(doz("artikal.obrisi")).Get("/magacin/vrati/{id}", h.VratiArtikal)
r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/dodaj", h.DodajDobavljacaArtiklu)
r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/obrisi", h.ObrisiDobavljacaArtikla)
r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal)
r.With(doz("artikal.izmeni")).Post("/magacin/promeni-cenu/{id}", h.PromeniCenuArtikla)
r.With(doz("artikal.izmeni")).Get("/nivelacije", h.Nivelacije)
@@ -337,16 +342,17 @@ func main() {
r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/stampa", h.StampaServisa)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/stampa", h.StampaServisa)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/predracun", h.StampaPredracuna)
r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus)
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi", h.DodajDeloNalogu)
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga)
r.Get("/izvestaji", h.Izvestaji)
r.Get("/izvestaji/prometni-list", h.PrometniListMagacina)
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
r.Get("/izvestaji/popis", h.Popis)
r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis)
r.Get("/izvestaji/prometni-list", h.PrometniListMagacina)
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
r.Get("/izvestaji/popis", h.Popis)
r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
r.Get("/prodaja/nova", h.NovaProdaja)
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
@@ -393,6 +399,7 @@ func main() {
r.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu)
r.With(doz("tema.lokalno")).Post("/profil/animacija", h.SacuvajLokalnuAnimaciju)
r.With(doz("tema.lokalno")).Post("/profil/hover", h.SacuvajLokalniHover)
r.With(doz("tema.lokalno")).Post("/profil/brzina-animacije", h.SacuvajLokalnuBrzinuAnimacije)
r.Get("/profil/tema", h.ProfilTema)
r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu)
r.With(doz("tema.lokalno")).Post("/profil/pozadina/ukloni", h.ProfilUkloniPozadinu)
+11
View File
@@ -27,6 +27,17 @@ func ProveriLozinku(hash, lozinka string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(lozinka)) == nil
}
// dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju.
// Koristi ga IzjednaciVremeProvere kada korisnik ne postoji.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost)
// IzjednaciVremeProvere izvršava bcrypt poređenje protiv fiksnog heša da bi vreme
// odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom —
// time se sprečava enumeracija korisničkih imena merenjem vremena odgovora.
func IzjednaciVremeProvere(lozinka string) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(lozinka))
}
// GenerisiToken generiše nasumičan UUID token za sesiju
func GenerisiToken() string {
return uuid.New().String()
+38 -3
View File
@@ -2,14 +2,20 @@ package db
import (
"context"
"errors"
"time"
"ntech/internal/model"
)
// ErrArtikalUUpotrebi se vraća kad se artikal ne može obrisati jer postoji u prometu
// (prodaja, nabavka, magacinske promene ili servisni nalozi). Tada se artikal arhivira.
var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi")
// ArtikalRepository definiše operacije nad artiklima
type ArtikalRepository interface {
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
PrebrojiPoFilteru(ctx context.Context, filter ArtikalFilter) (int, error)
DohvatiID(ctx context.Context, id int64) (*model.Artikal, error)
Kreiraj(ctx context.Context, a *model.Artikal) (int64, error)
Izmeni(ctx context.Context, a *model.Artikal) error
@@ -17,10 +23,24 @@ type ArtikalRepository interface {
AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error
PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error
Obrisi(ctx context.Context, id int64) error
// SledecaSifra vraća predlog sledeće auto-šifre (npr. ART-00042)
SledecaSifra(ctx context.Context) (string, error)
// Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju)
Arhiviraj(ctx context.Context, id int64) error
// Vrati poništava arhiviranje i vraća artikal u aktivnu listu
Vrati(ctx context.Context, id int64) error
// SledecaSifra vraća predlog sledeće auto-šifre (npr. KOMP-0042 ili ART-0042)
SledecaSifra(ctx context.Context, kategorijaID *int64) (string, error)
// KorigujKolicinu postavlja novu količinu artikla i upisuje korekciju u magacinske_promene
KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error
// DobavljaciArtikla vraća ID-jeve dobavljača vezanih za artikal
DobavljaciArtikla(ctx context.Context, artikalID int64) ([]int64, error)
// PostaviDobavljaceArtikla zamenjuje skup dobavljača artikla datim ID-jevima
PostaviDobavljaceArtikla(ctx context.Context, artikalID int64, dobavljaciID []int64) error
// PoveziDobavljaca dodaje vezu artikaldobavljač ako ne postoji (auto pri nabavci)
PoveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error
// OdveziDobavljaca uklanja vezu artikaldobavljač
OdveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error
// SveDobavljaceArtikala vraća mapu artikal_id → lista dobavljac_id (za filter u nabavci)
SveDobavljaceArtikala(ctx context.Context) (map[int64][]int64, error)
}
// KategorijaRepository definiše operacije nad kategorijama
@@ -78,6 +98,9 @@ type ArtikalFilter struct {
Pretraga string
KategorijaID *int64
SamoKriticni bool
Arhivirani bool // true → vrati samo arhivirane; false (podrazumevano) → samo aktivne
Limit int
Offset int
}
// NabavkaRepository definiše operacije nad nabavkama
@@ -99,9 +122,19 @@ type DobavljacRepository interface {
Obrisi(ctx context.Context, id int64) error
}
// KlijentFilter definiše parametre za filtriranje liste klijenata
type KlijentFilter struct {
Pretraga string
Tip string // "fizicko", "pravno" ili "" za sve
Limit int
Offset int
}
// KlijentRepository definiše operacije nad klijentima
type KlijentRepository interface {
Lista(ctx context.Context, pretraga string) ([]model.Klijent, error)
ListaFilter(ctx context.Context, filter KlijentFilter) ([]model.Klijent, error)
PrebrojiPoFilteru(ctx context.Context, filter KlijentFilter) (int, error)
DohvatiID(ctx context.Context, id int64) (*model.Klijent, error)
Kreiraj(ctx context.Context, k *model.Klijent) (int64, error)
Izmeni(ctx context.Context, k *model.Klijent) error
@@ -112,6 +145,7 @@ type KlijentRepository interface {
type ServisRepository interface {
Lista(ctx context.Context, pretraga, status string) ([]model.ServisniNalogSaKlijentom, error)
DohvatiID(ctx context.Context, id int64) (*model.ServisniNalog, error)
DohvatiJavniToken(ctx context.Context, token string) (*model.ServisniNalog, error)
Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error)
Izmeni(ctx context.Context, n *model.ServisniNalog) error
AzurirajStatus(ctx context.Context, id int64, status string) error
@@ -126,7 +160,7 @@ type ProdajaRepository interface {
DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error)
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error)
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
Obrisi(ctx context.Context, id int64) error
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
SledeciBroj(ctx context.Context) (string, error)
}
@@ -156,6 +190,7 @@ type KorisniciRepository interface {
SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error
SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error
SacuvajLokalniHover(ctx context.Context, id int64, hover string) error
SacuvajLokalnuBrzinuAnimacije(ctx context.Context, id int64, brzina string) error
SacuvajAvatar(ctx context.Context, id int64, putanja string) error
PostojiIjedan(ctx context.Context) (bool, error)
Obrisi(ctx context.Context, id int64) error
+224 -23
View File
@@ -3,10 +3,15 @@ package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"ntech/internal/db"
"ntech/internal/model"
mosqlite "modernc.org/sqlite"
)
// ArtikalRepo je SQLite implementacija ArtikalRepository interfejsa
@@ -23,9 +28,9 @@ func NoviArtikalRepo(db *sql.DB) *ArtikalRepo {
func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]model.ArtikalSaKategorijom, error) {
upit := `
SELECT
a.id, a.kategorija_id, a.sifra, a.barkod, a.naziv, a.opis,
a.id, a.kategorija_id, a.sifra, a.barkod, a.naziv, a.opis, a.tip, a.jedinica_mere,
a.kolicina, a.kolicina_min, a.lokacija,
a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.marza, a.napomena, a.datum_unosa,
a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.marza, a.napomena, a.datum_unosa, a.arhiviran,
COALESCE(k.naziv, '') as kategorija_naziv, k.marza as kategorija_marza
FROM artikli a
LEFT JOIN kategorije k ON a.kategorija_id = k.id
@@ -45,11 +50,27 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
}
if filter.SamoKriticni {
upit += " AND a.kolicina <= a.kolicina_min"
upit += " AND (a.tip = 'proizvod' OR a.tip = '') AND a.kolicina <= a.kolicina_min"
}
// podrazumevano vraćamo samo aktivne; arhivirani se prikazuju samo na izričit zahtev
if filter.Arhivirani {
upit += " AND a.arhiviran = 1"
} else {
upit += " AND a.arhiviran = 0"
}
upit += " ORDER BY a.naziv ASC"
if filter.Limit > 0 {
upit += " LIMIT ?"
args = append(args, filter.Limit)
if filter.Offset > 0 {
upit += " OFFSET ?"
args = append(args, filter.Offset)
}
}
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: %w", err)
@@ -62,16 +83,18 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
var kategorijaID sql.NullInt64
var sifra, barkod sql.NullString
var marza, katMarza sql.NullFloat64
var arhiviran int
err := redovi.Scan(
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis,
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere,
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
&a.KategorijaNaziv, &katMarza,
)
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: scan: %w", err)
}
a.Arhiviran = arhiviran == 1
if kategorijaID.Valid {
a.KategorijaID = &kategorijaID.Int64
@@ -89,7 +112,8 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
a.KategorijaMarza = &katMarza.Float64
}
a.KriticnaZaliha = a.Kolicina <= a.KolicinMin
// kritična zaliha važi samo za proizvode (usluge/troškovi nemaju lager)
a.KriticnaZaliha = a.PratiLager() && a.Kolicina <= a.KolicinMin
rezultat = append(rezultat, a)
}
@@ -103,18 +127,20 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
var kategorijaID sql.NullInt64
var sifra, barkod sql.NullString
var marza sql.NullFloat64
var arhiviran int
err := r.db.QueryRowContext(ctx, `
SELECT id, kategorija_id, sifra, barkod, naziv, opis, kolicina, kolicina_min,
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa
SELECT id, kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min,
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa, arhiviran
FROM artikli WHERE id = ?`, id).Scan(
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis,
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere,
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
)
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
}
a.Arhiviran = arhiviran == 1
if kategorijaID.Valid {
a.KategorijaID = &kategorijaID.Int64
@@ -144,10 +170,10 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO artikli
(kategorija_id, sifra, barkod, naziv, opis, kolicina, kolicina_min, lokacija,
(kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, lokacija,
nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, a.Kolicina, a.KolicinMin,
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena,
)
if err != nil {
@@ -174,12 +200,12 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
_, err := r.db.ExecContext(ctx, `
UPDATE artikli SET
kategorija_id = ?, sifra = ?, barkod = ?, naziv = ?, opis = ?, kolicina = ?,
kolicina_min = ?, lokacija = ?,
kategorija_id = ?, sifra = ?, barkod = ?, naziv = ?, opis = ?, tip = ?, jedinica_mere = ?,
kolicina = ?, kolicina_min = ?, lokacija = ?,
nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, marza = ?, napomena = ?
WHERE id = ?`,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Kolicina,
a.KolicinMin, a.Lokacija,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere,
a.Kolicina, a.KolicinMin, a.Lokacija,
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, a.ID,
)
if err != nil {
@@ -189,14 +215,43 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
return nil
}
// SledecaSifra vraća predlog sledeće auto-šifre u formatu ART-XXXXX
func (r *ArtikalRepo) SledecaSifra(ctx context.Context) (string, error) {
var n int64
err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM artikli").Scan(&n)
// SledecaSifra vraća predlog sledeće auto-šifre u formatu PREFIKS-NNNN.
// Prefiks je kôd kategorije (npr. KOMP) ili "ART" ako kategorija nema kôd ili nije zadata.
// Brojač je najveći postojeći broj za taj prefiks + 1 (otporno na brisanje artikala).
func (r *ArtikalRepo) SledecaSifra(ctx context.Context, kategorijaID *int64) (string, error) {
prefiks := "ART"
if kategorijaID != nil {
var kod sql.NullString
if err := r.db.QueryRowContext(ctx,
"SELECT kod FROM kategorije WHERE id = ?", *kategorijaID).Scan(&kod); err == nil {
if kod.Valid && kod.String != "" {
prefiks = kod.String
}
}
}
redovi, err := r.db.QueryContext(ctx, "SELECT sifra FROM artikli WHERE sifra LIKE ?", prefiks+"-%")
if err != nil {
return "", fmt.Errorf("ntech: ArtikalRepo.SledecaSifra: %w", err)
}
return fmt.Sprintf("ART-%05d", n+1), nil
defer redovi.Close()
maxBroj := 0
for redovi.Next() {
var s string
if err := redovi.Scan(&s); err != nil {
continue
}
i := strings.LastIndex(s, "-")
if i < 0 {
continue
}
if n, err := strconv.Atoi(s[i+1:]); err == nil && n > maxBroj {
maxBroj = n
}
}
return fmt.Sprintf("%s-%04d", prefiks, maxBroj+1), nil
}
// AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe).
@@ -220,16 +275,123 @@ func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategori
return nil
}
// Obrisi briše artikal po ID-u
// Obrisi briše artikal po ID-u. Ako je artikal u prometu (FK RESTRICT), vraća
// db.ErrArtikalUUpotrebi kako bi pozivalac mogao da ga arhivira umesto da ga obriše.
func (r *ArtikalRepo) Obrisi(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM artikli WHERE id = ?", id)
if err != nil {
// SQLITE_CONSTRAINT_FOREIGNKEY (787) — artikal je referenciran iz druge tabele
var sqliteErr *mosqlite.Error
if errors.As(err, &sqliteErr) && sqliteErr.Code() == 787 {
return db.ErrArtikalUUpotrebi
}
return fmt.Errorf("ntech: ArtikalRepo.Obrisi: %w", err)
}
return nil
}
// Arhiviraj označava artikal kao arhiviran (soft delete za artikle u prometu)
func (r *ArtikalRepo) Arhiviraj(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "UPDATE artikli SET arhiviran = 1 WHERE id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.Arhiviraj: %w", err)
}
return nil
}
// Vrati poništava arhiviranje i vraća artikal u aktivnu listu
func (r *ArtikalRepo) Vrati(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "UPDATE artikli SET arhiviran = 0 WHERE id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.Vrati: %w", err)
}
return nil
}
// DobavljaciArtikla vraća ID-jeve dobavljača vezanih za artikal
func (r *ArtikalRepo) DobavljaciArtikla(ctx context.Context, artikalID int64) ([]int64, error) {
redovi, err := r.db.QueryContext(ctx,
"SELECT dobavljac_id FROM artikal_dobavljac WHERE artikal_id = ? ORDER BY dobavljac_id", artikalID)
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DobavljaciArtikla: %w", err)
}
defer redovi.Close()
var ids []int64
for redovi.Next() {
var id int64
if err := redovi.Scan(&id); err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DobavljaciArtikla: scan: %w", err)
}
ids = append(ids, id)
}
return ids, nil
}
// PostaviDobavljaceArtikla zamenjuje skup dobavljača artikla datim ID-jevima (u transakciji)
func (r *ArtikalRepo) PostaviDobavljaceArtikla(ctx context.Context, artikalID int64, dobavljaciID []int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, "DELETE FROM artikal_dobavljac WHERE artikal_id = ?", artikalID); err != nil {
return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: delete: %w", err)
}
for _, did := range dobavljaciID {
if _, err := tx.ExecContext(ctx,
"INSERT OR IGNORE INTO artikal_dobavljac (artikal_id, dobavljac_id) VALUES (?, ?)", artikalID, did); err != nil {
return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: insert: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: commit: %w", err)
}
return nil
}
// PoveziDobavljaca dodaje vezu artikaldobavljač ako ne postoji (auto pri nabavci)
func (r *ArtikalRepo) PoveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error {
_, err := r.db.ExecContext(ctx,
"INSERT OR IGNORE INTO artikal_dobavljac (artikal_id, dobavljac_id) VALUES (?, ?)", artikalID, dobavljacID)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.PoveziDobavljaca: %w", err)
}
return nil
}
// OdveziDobavljaca uklanja vezu artikaldobavljač
func (r *ArtikalRepo) OdveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error {
_, err := r.db.ExecContext(ctx,
"DELETE FROM artikal_dobavljac WHERE artikal_id = ? AND dobavljac_id = ?", artikalID, dobavljacID)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.OdveziDobavljaca: %w", err)
}
return nil
}
// SveDobavljaceArtikala vraća mapu artikal_id → lista dobavljac_id
func (r *ArtikalRepo) SveDobavljaceArtikala(ctx context.Context) (map[int64][]int64, error) {
redovi, err := r.db.QueryContext(ctx,
"SELECT artikal_id, dobavljac_id FROM artikal_dobavljac ORDER BY artikal_id")
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.SveDobavljaceArtikala: %w", err)
}
defer redovi.Close()
mapa := make(map[int64][]int64)
for redovi.Next() {
var aid, did int64
if err := redovi.Scan(&aid, &did); err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.SveDobavljaceArtikala: scan: %w", err)
}
mapa[aid] = append(mapa[aid], did)
}
return mapa, nil
}
// KorigujKolicinu postavlja novu količinu i upisuje korekciju u magacinske_promene
func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error {
tx, err := r.db.BeginTx(ctx, nil)
@@ -258,3 +420,42 @@ func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, nova
return tx.Commit()
}
// PrebrojiPoFilteru vraća ukupan broj artikala koji zadovoljavaju filter (bez LIMIT/OFFSET)
func (r *ArtikalRepo) PrebrojiPoFilteru(ctx context.Context, filter db.ArtikalFilter) (int, error) {
upit := `
SELECT COUNT(*)
FROM artikli a
LEFT JOIN kategorije k ON a.kategorija_id = k.id
WHERE 1=1`
args := []any{}
if filter.Pretraga != "" {
upit += " AND (a.naziv LIKE ? OR a.sifra LIKE ? OR a.barkod LIKE ? OR a.lokacija LIKE ? OR k.naziv LIKE ?)"
t := "%" + filter.Pretraga + "%"
args = append(args, t, t, t, t, t)
}
if filter.KategorijaID != nil {
upit += " AND a.kategorija_id = ?"
args = append(args, *filter.KategorijaID)
}
if filter.SamoKriticni {
upit += " AND (a.tip = 'proizvod' OR a.tip = '') AND a.kolicina <= a.kolicina_min"
}
if filter.Arhivirani {
upit += " AND a.arhiviran = 1"
} else {
upit += " AND a.arhiviran = 0"
}
var broj int
if err := r.db.QueryRowContext(ctx, upit, args...).Scan(&broj); err != nil {
return 0, fmt.Errorf("ntech: ArtikalRepo.PrebrojiPoFilteru: %w", err)
}
return broj, nil
}
+24 -10
View File
@@ -20,7 +20,7 @@ func NovaKategorijaRepo(db *sql.DB) *KategorijaRepo {
// Lista vraća sve kategorije
func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) {
redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis, marza FROM kategorije ORDER BY naziv ASC")
redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis, kod, marza FROM kategorije ORDER BY naziv ASC")
if err != nil {
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: %w", err)
}
@@ -29,14 +29,17 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
var rezultat []model.Kategorija
for redovi.Next() {
var k model.Kategorija
var opis sql.NullString
var opis, kod sql.NullString
var marza sql.NullFloat64
if err := redovi.Scan(&k.ID, &k.Naziv, &opis, &marza); err != nil {
if err := redovi.Scan(&k.ID, &k.Naziv, &opis, &kod, &marza); err != nil {
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: scan: %w", err)
}
if opis.Valid {
k.Opis = opis.String
}
if kod.Valid {
k.Kod = kod.String
}
if marza.Valid {
k.Marza = &marza.Float64
}
@@ -48,9 +51,13 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
// Kreiraj dodaje novu kategoriju
func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) {
var kod any
if k.Kod != "" {
kod = k.Kod
}
rezultat, err := r.db.ExecContext(ctx,
"INSERT INTO kategorije (naziv, opis, marza) VALUES (?, ?, ?)",
k.Naziv, k.Opis, k.Marza,
"INSERT INTO kategorije (naziv, opis, kod, marza) VALUES (?, ?, ?, ?)",
k.Naziv, k.Opis, kod, k.Marza,
)
if err != nil {
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err)
@@ -67,17 +74,20 @@ func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int6
// DohvatiID vraća jednu kategoriju po ID-u
func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) {
var k model.Kategorija
var opis sql.NullString
var opis, kod sql.NullString
var marza sql.NullFloat64
err := r.db.QueryRowContext(ctx,
"SELECT id, naziv, opis, marza FROM kategorije WHERE id = ?", id).
Scan(&k.ID, &k.Naziv, &opis, &marza)
"SELECT id, naziv, opis, kod, marza FROM kategorije WHERE id = ?", id).
Scan(&k.ID, &k.Naziv, &opis, &kod, &marza)
if err != nil {
return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err)
}
if opis.Valid {
k.Opis = opis.String
}
if kod.Valid {
k.Kod = kod.String
}
if marza.Valid {
k.Marza = &marza.Float64
}
@@ -86,9 +96,13 @@ func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Katego
// Izmeni ažurira naziv, opis i maržu postojeće kategorije
func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error {
var kod any
if k.Kod != "" {
kod = k.Kod
}
_, err := r.db.ExecContext(ctx,
"UPDATE kategorije SET naziv = ?, opis = ?, marza = ? WHERE id = ?",
k.Naziv, k.Opis, k.Marza, k.ID,
"UPDATE kategorije SET naziv = ?, opis = ?, kod = ?, marza = ? WHERE id = ?",
k.Naziv, k.Opis, kod, k.Marza, k.ID,
)
if err != nil {
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
+87 -3
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"ntech/internal/db"
"ntech/internal/model"
)
@@ -28,9 +29,9 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
args := []any{}
if pretraga != "" {
upit += " AND (ime LIKE ? OR prezime LIKE ? OR naziv_firme LIKE ?)"
p := "%" + pretraga + "%"
args = append(args, p, p, p)
upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)"
p := "%" + pretraga + "%"
args = append(args, p, p, p, p, p, p)
}
upit += " ORDER BY datum_unosa DESC"
@@ -138,6 +139,89 @@ func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error {
return nil
}
// ListaFilter vraća listu klijenata sa limitom i offsetom (paginacija)
func (r *KlijentRepo) ListaFilter(ctx context.Context, filter db.KlijentFilter) ([]model.Klijent, error) {
upit := `
SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti
WHERE 1=1`
args := []any{}
if filter.Pretraga != "" {
upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)"
p := "%" + filter.Pretraga + "%"
args = append(args, p, p, p, p, p, p)
}
if filter.Tip == "fizicko" || filter.Tip == "pravno" {
upit += " AND tip = ?"
args = append(args, filter.Tip)
}
upit += " ORDER BY datum_unosa DESC"
if filter.Limit > 0 {
upit += " LIMIT ?"
args = append(args, filter.Limit)
if filter.Offset > 0 {
upit += " OFFSET ?"
args = append(args, filter.Offset)
}
}
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: KlijentRepo.ListaFilter: %w", err)
}
defer redovi.Close()
var rezultat []model.Klijent
for redovi.Next() {
var k model.Klijent
var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString
err := redovi.Scan(
&k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa,
)
if err != nil {
return nil, fmt.Errorf("ntech: KlijentRepo.ListaFilter: scan: %w", err)
}
k.Ime = ime.String
k.Prezime = prezime.String
k.JMBG = jmbg.String
k.NazivFirme = nazivFirme.String
k.PIB = pib.String
k.Telefon = telefon.String
k.Email = email.String
k.Mesto = mesto.String
k.Napomena = napomena.String
rezultat = append(rezultat, k)
}
return rezultat, nil
}
// PrebrojiPoFilteru vraća broj klijenata koji zadovoljavaju filter
func (r *KlijentRepo) PrebrojiPoFilteru(ctx context.Context, filter db.KlijentFilter) (int, error) {
upit := `SELECT COUNT(*) FROM klijenti WHERE 1=1`
args := []any{}
if filter.Pretraga != "" {
upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)"
p := "%" + filter.Pretraga + "%"
args = append(args, p, p, p, p, p, p)
}
if filter.Tip == "fizicko" || filter.Tip == "pravno" {
upit += " AND tip = ?"
args = append(args, filter.Tip)
}
var broj int
if err := r.db.QueryRowContext(ctx, upit, args...).Scan(&broj); err != nil {
return 0, fmt.Errorf("ntech: KlijentRepo.PrebrojiPoFilteru: %w", err)
}
return broj, nil
}
// Obrisi briše klijenta po ID-u
func (r *KlijentRepo) Obrisi(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM klijenti WHERE id = ?", id)
+23 -5
View File
@@ -32,6 +32,7 @@ type korisnikOpcije struct {
avatarPutanja sql.NullString
lokalnaAnimacija sql.NullString
lokalniHover sql.NullString
lokalnaBrzinaAnimacije sql.NullString
}
// dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik
@@ -48,6 +49,7 @@ func dodeliOpcijeKorisnika(k *model.Korisnik, o korisnikOpcije) {
k.AvatarPutanja = o.avatarPutanja.String
k.LokalnaAnimacija = o.lokalnaAnimacija.String
k.LokalniHover = o.lokalniHover.String
k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String
}
// skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik
@@ -59,7 +61,7 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er
&o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
); err != nil {
return nil, err
}
@@ -104,7 +106,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme
COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '')
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme)
k, err := skeniraiKorisnika(row)
if err != nil {
@@ -121,7 +124,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model
COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '')
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
FROM korisnici WHERE id = ?`, id)
k, err := skeniraiKorisnika(row)
if err != nil {
@@ -138,7 +142,8 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '')
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
FROM korisnici ORDER BY datum_kreiranja ASC`)
if err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
@@ -153,7 +158,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
&o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
); err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
}
@@ -229,6 +234,19 @@ func (r *sqliteKorisniciRepo) SacuvajLokalniHover(ctx context.Context, id int64,
return nil
}
func (r *sqliteKorisniciRepo) SacuvajLokalnuBrzinuAnimacije(ctx context.Context, id int64, brzina string) error {
var val any
if brzina != "" {
val = brzina
}
_, err := r.db.ExecContext(ctx,
`UPDATE korisnici SET lokalna_brzina_animacije = ? WHERE id = ?`, val, id)
if err != nil {
return fmt.Errorf("ntech: korisnici.SacuvajLokalnuBrzinuAnimacije: %w", err)
}
return nil
}
func (r *sqliteKorisniciRepo) AzurirajUlogu(ctx context.Context, id int64, uloga string) error {
_, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id)
if err != nil {
+56 -24
View File
@@ -123,7 +123,7 @@ func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniN
func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno,
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv, a.jedinica_mere
FROM stavke_prodaje sp
JOIN artikli a ON a.id = sp.artikal_id
WHERE sp.nalog_id = ?
@@ -140,7 +140,7 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model
&s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
&s.CenaPoKomadu, &s.Ukupno,
&s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv,
&s.ArtikalNaziv,
&s.ArtikalNaziv, &s.JedinicaMere,
)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err)
@@ -182,25 +182,29 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
// provera stanja, smanjenje i insert stavki
for _, s := range stavke {
var naziv string
var naziv, tip string
var stanjePre int
err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &stanjePre)
"SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &stanjePre, &tip)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
}
if stanjePre < s.Kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
stanjePosle := stanjePre - s.Kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
// usluge i troškovi ne prate lager — ne proveravaju se i ne umanjuju
pratiLager := tip == model.TipProizvod || tip == ""
stanjePosle := stanjePre
if pratiLager {
if stanjePre < s.Kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
stanjePosle = stanjePre - s.Kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
}
}
// PDV računamo iz cene ako nije eksplicitno postavljeno
@@ -223,10 +227,13 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err)
// magacinsku promenu beležimo samo za artikle koji prate lager
if pratiLager {
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err)
}
}
}
@@ -279,11 +286,17 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
for _, p := range stavke {
var stanjePre int
err := tx.QueryRowContext(ctx, "SELECT kolicina FROM artikli WHERE id = ?", p.artikalID).Scan(&stanjePre)
var tip string
err := tx.QueryRowContext(ctx, "SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID).Scan(&stanjePre, &tip)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stanje: %w", err)
}
// usluge i troškovi nemaju stanje na lageru — preskačemo povraćaj
if !(tip == model.TipProizvod || tip == "") {
continue
}
stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
@@ -315,7 +328,7 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
}
// Obrisi briše prodajni nalog i vraća količine artikala na stanje (u transakciji)
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: begin tx: %w", err)
@@ -352,13 +365,32 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
redovi.Close()
for _, p := range stavke {
_, err := tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?",
p.kolicina, p.artikalID,
// usluge i troškovi nemaju stanje — vraćamo samo proizvodima
var stanjePre int
var tip string
err := tx.QueryRowContext(ctx,
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
).Scan(&stanjePre, &tip)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stanje: %w", err)
}
if !(tip == model.TipProizvod || tip == "") {
continue
}
stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, id, korisnikID, "brisanje prodajnog naloga")
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: magacin: %w", err)
}
}
}
+42 -8
View File
@@ -2,13 +2,24 @@ package sqlite
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"time"
"ntech/internal/model"
)
// generisiJavniToken kreira 32-znakovni hex token za javni URL
func generisiJavniToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// ServisRepo je SQLite implementacija ServisRepository interfejsa
type ServisRepo struct {
db *sql.DB
@@ -43,7 +54,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
sn.opis_kvara, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
sn.avans, sn.napomena, sn.garancija_do, sn.datum_prijema, sn.datum_zavrsetka,
sn.ostecenja, sn.pin_uredjaja, sn.pribor,
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.javni_token,
COALESCE(kp.naziv, '') AS klijent_naziv
FROM servisni_nalozi sn
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
@@ -90,7 +101,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka,
ostecenja, pin_uredjaja, pribor
ostecenja, pin_uredjaja, pribor, javni_token
FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog
@@ -102,21 +113,26 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
return &n, nil
}
// Kreiraj upisuje novi servisni nalog u bazu
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
token, err := generisiJavniToken()
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: token: %w", err)
}
rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO servisni_nalozi
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara,
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka,
ostecenja, pin_uredjaja, pribor, datum_prijema)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
nullString(n.SerijskiBroj), n.OpisKvara, n.Status,
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.DatumPrijema,
n.DatumPrijema, token,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err)
@@ -130,6 +146,23 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
return id, nil
}
// DohvatiJavniToken vraća servisni nalog po javnom tokenu — bez autentifikacije
func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*model.ServisniNalog, error) {
red := r.db.QueryRowContext(ctx, `
SELECT
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka,
ostecenja, pin_uredjaja, pribor, javni_token
FROM servisni_nalozi WHERE javni_token = ?`, token)
var n model.ServisniNalog
if err := scanNalog(red.Scan, &n, nil); err != nil {
return nil, fmt.Errorf("ntech: ServisRepo.DohvatiJavniToken: %w", err)
}
return &n, nil
}
// Izmeni ažurira postojeći servisni nalog — broj_naloga i datum_prijema se ne menjaju
func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
_, err := r.db.ExecContext(ctx, `
@@ -184,7 +217,7 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64) error {
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error {
var klijentID, tehnicarID sql.NullInt64
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor sql.NullString
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, javniToken sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka sql.NullTime
@@ -192,7 +225,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka,
&ostecenja, &pinUredjaja, &pribor,
&ostecenja, &pinUredjaja, &pribor, &javniToken,
}
if klijentNaziv != nil {
@@ -240,6 +273,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := datumZavrsetka.Time
n.DatumZavrsetka = &v
}
n.JavniToken = javniToken.String
return nil
}
+9 -18
View File
@@ -126,8 +126,7 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Korisnik je uspešno kreiran.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminToggleAktivan menja aktivan status korisnika
@@ -172,8 +171,7 @@ func (h *Handler) AdminToggleAktivan(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminPromeniUlogu menja ulogu korisnika
@@ -234,8 +232,7 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminObrisiKorisnika briše korisnika sa ulogom radnik
@@ -279,8 +276,7 @@ func (h *Handler) AdminObrisiKorisnika(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Korisnik je obrisan.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminProfil prikazuje stranicu profila
@@ -406,8 +402,7 @@ func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Lozinka je uspešno promenjena.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
}
// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod
@@ -529,8 +524,7 @@ func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request)
// isključenjem 2FA brišemo i rezervne kodove
_ = h.RezervniKodoviRepo.Obrisi(r.Context(), k.ID)
middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je isključena.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
}
// AdminLoginIstorija prikazuje evidenciju prijava za datog korisnika
@@ -679,8 +673,7 @@ func (h *Handler) AdminDozvolePromeniUlogu(w http.ResponseWriter, r *http.Reques
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
}
// AdminDozvoleSacuvaj prima POST i čuva promene dozvola za radnik i admin uloge
@@ -711,8 +704,7 @@ func (h *Handler) AdminDozvoleSacuvaj(w http.ResponseWriter, r *http.Request) {
}
}
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
}
// AdminDozvoleReset vraća sve dozvole na podrazumevane vrednosti — samo superadmin
@@ -727,6 +719,5 @@ func (h *Handler) AdminDozvoleReset(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Dozvole su vraćene na podrazumevane vrednosti.")
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
}
+1
View File
@@ -215,6 +215,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
if korisnik != nil {
ps.LokalnaAnimacija = korisnik.LokalnaAnimacija
ps.LokalniHover = korisnik.LokalniHover
ps.LokalnaBrzinaAnimacije = korisnik.LokalnaBrzinaAnimacije
}
return ps
+15
View File
@@ -65,6 +65,7 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
k := &model.Kategorija{
Naziv: naziv,
Opis: r.FormValue("opis"),
Kod: normalizujKod(r.FormValue("kod")),
Marza: parsirajMarzu(r.FormValue("marza")),
}
@@ -102,6 +103,7 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) {
ID: id,
Naziv: naziv,
Opis: r.FormValue("opis"),
Kod: normalizujKod(r.FormValue("kod")),
Marza: parsirajMarzu(r.FormValue("marza")),
}
@@ -126,6 +128,19 @@ func parsirajMarzu(s string) *float64 {
return &v
}
// normalizujKod čisti kôd kategorije za upotrebu kao prefiks šifre:
// velika slova, zadržava samo slova i brojeve (bez razmaka i specijalnih znakova).
func normalizujKod(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
var b strings.Builder
for _, r := range s {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
// ObrisiKategoriju briše kategoriju po ID-u
func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok {
+131 -2
View File
@@ -5,7 +5,9 @@ import (
"html/template"
"io/fs"
"log/slog"
"math"
"net/http"
"strings"
)
var bazniSabloni = []string{
@@ -38,13 +40,44 @@ var saSidebar = []string{
// standalone su šabloni bez base layouta
var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica",
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_predracun", "servis_status_javni",
}
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
// dict gradi mapu iz parova ključ/vrednost — koristi se da se jednom partialu
// prosledi više vrednosti (npr. {{template "x" (dict "ID" .ID "Lista" $.Lista)}}).
var sablonskeFunkcije = template.FuncMap{
// formatBroj formatira float pointer kao ceo broj (zaokružen) — nil vraca ""
"formatBroj": func(v *float64) string {
if v == nil {
return ""
}
return fmt.Sprintf("%d", int64(math.Round(*v)))
},
// dinari formatira iznos sa separatorom hiljada (tačka) i 2 decimale (zarez):
// 1234567.5 → "1.234.567,50"
"dinari": func(v float64) string {
return formatirajDinare(v, 2)
},
// dinariCeli formatira iznos sa separatorom hiljada, bez decimala: 1234567 → "1.234.567"
"dinariCeli": func(v float64) string {
return formatirajDinare(v, 0)
},
// telefon formatira srpski broj telefona radi lakšeg čitanja: "0641234567" → "064 123 4567"
"telefon": formatirajTelefon,
// statusPre vraća true ako je `a` pre `b` u redosledu statusa
"statusPre": func(a, b string, statusi []string) bool {
ia, ib := -1, -1
for i, s := range statusi {
if s == a {
ia = i
}
if s == b {
ib = i
}
}
return ia >= 0 && ib >= 0 && ia < ib
},
"dict": func(parovi ...any) (map[string]any, error) {
if len(parovi)%2 != 0 {
return nil, fmt.Errorf("dict: neparan broj argumenata")
@@ -77,7 +110,8 @@ func KreirajKes(fsys fs.FS) (map[string]*template.Template, error) {
}
for _, ime := range standaloneIme {
t, err := template.ParseFS(fsys, "web/templates/stranice/"+ime+".html")
// ime+".html" mora biti ime roota da bi Execute() pronašlo sadržaj fajla
t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, "web/templates/stranice/"+ime+".html")
if err != nil {
return nil, fmt.Errorf("kes: %s: %w", ime, err)
}
@@ -150,3 +184,98 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
}
// formatirajDinare formatira broj sa tačkom kao separatorom hiljada i zarezom
// za decimale (srpski format). decimale = broj decimalnih mesta (0 ili 2).
func formatirajDinare(v float64, decimale int) string {
negativan := v < 0
if negativan {
v = -v
}
var ceoStr, decStr string
if decimale == 2 {
// radi u stotinkama da zaokruživanje pravilno prenese (npr. 1234567.999 → 1.234.568,00)
stotinke := int64(math.Round(v * 100))
ceoStr = fmt.Sprintf("%d", stotinke/100)
decStr = fmt.Sprintf("%02d", stotinke%100)
} else {
ceoStr = fmt.Sprintf("%d", int64(math.Round(v)))
}
// ubaci tačke na svake 3 cifre s desna
var sb []byte
n := len(ceoStr)
for i, c := range ceoStr {
if i > 0 && (n-i)%3 == 0 {
sb = append(sb, '.')
}
sb = append(sb, byte(c))
}
rezultat := string(sb)
if decimale == 2 {
rezultat += "," + decStr
}
if negativan {
rezultat = "-" + rezultat
}
return rezultat
}
// formatirajTelefon formatira srpski broj telefona radi lakšeg čitanja:
// pozivni broj odvojen kosom crtom, ostatak grupisan crticom.
// Primeri: "0641234567" → "064/123-4567", "+381641234567" → "+381 64/123-4567".
// Ako format nije prepoznat, vraća original.
func formatirajTelefon(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
// izdvoj cifre i zapamti da li je međunarodni (+)
medjunarodni := strings.HasPrefix(s, "+") || strings.HasPrefix(s, "00")
var cifre []rune
for _, c := range s {
if c >= '0' && c <= '9' {
cifre = append(cifre, c)
}
}
d := string(cifre)
// međunarodni srpski prefiks (381): "+381 64/123-4567"
if medjunarodni {
d = strings.TrimPrefix(d, "00")
if strings.HasPrefix(d, "381") {
ostatak := d[3:] // bez vodeće nule, npr. "641234567"
if len(ostatak) < 7 || len(ostatak) > 9 {
return s
}
return "+381 " + ostatak[:2] + "/" + grupisiTelefon(ostatak[2:])
}
return s // strani broj — ne diramo
}
// lokalni format: očekujemo vodeću nulu i 810 cifara ukupno
if !strings.HasPrefix(d, "0") || len(d) < 8 || len(d) > 10 {
return s
}
// pozivni (3 cifre, npr. 064/011) "/" ostatak grupisan crticom
return d[:3] + "/" + grupisiTelefon(d[3:])
}
// grupisiTelefon deli niz cifara u grupe od po 3 crticom (poslednja može 4) — "1234567" → "123-4567"
func grupisiTelefon(d string) string {
if len(d) <= 4 {
return d
}
var delovi []string
delovi = append(delovi, d[:3])
ostatak := d[3:]
for len(ostatak) > 4 {
delovi = append(delovi, ostatak[:3])
ostatak = ostatak[3:]
}
delovi = append(delovi, ostatak)
return strings.Join(delovi, "-")
}
+71 -11
View File
@@ -3,8 +3,10 @@ package handler
import (
"log/slog"
"net/http"
"strconv"
"strings"
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/model"
@@ -14,10 +16,17 @@ import (
// PodaciKlijenata su podaci za stranicu sa listom klijenata
type PodaciKlijenata struct {
model.PodaciStranice
Klijenti []model.Klijent
Pretraga string
Sacuvano bool
Obrisan bool
Klijenti []model.Klijent
Pretraga string
TipFilter string
Sacuvano bool
Obrisan bool
StranicaBr int
UkupnoStranica int
UkupnoKlijenata int
StranicaPrev int
StranicaNext int
StranicaQueryUrl string
}
// PodaciFormeKlijenta su podaci za formu novog/izmenjenog klijenta
@@ -28,7 +37,7 @@ type PodaciFormeKlijenta struct {
Izmena bool
}
// Klijenti renderuje listu svih klijenata sa opcionom pretragom
// Klijenti renderuje listu svih klijenata sa opcionom pretragom i paginacijom
func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
@@ -37,22 +46,73 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) {
}
pretraga := r.URL.Query().Get("pretraga")
tipFilter := r.URL.Query().Get("tip")
if tipFilter != "fizicko" && tipFilter != "pravno" {
tipFilter = ""
}
klijenti, err := h.KlijentiRepo.Lista(r.Context(), pretraga)
const pageSize = 50
stranicaBr := 1
if p := r.URL.Query().Get("stranica"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 {
stranicaBr = v
}
}
filter := db.KlijentFilter{
Pretraga: pretraga,
Tip: tipFilter,
Limit: pageSize,
Offset: (stranicaBr - 1) * pageSize,
}
klijenti, err := h.KlijentiRepo.ListaFilter(r.Context(), filter)
if err != nil {
http.Error(w, "Greška pri učitavanju klijenata", http.StatusInternalServerError)
return
}
ukupno, err := h.KlijentiRepo.PrebrojiPoFilteru(r.Context(), filter)
if err != nil {
http.Error(w, "Greška pri učitavanju klijenata", http.StatusInternalServerError)
return
}
ukupnoStranica := (ukupno + pageSize - 1) / pageSize
queryDelići := ""
if pretraga != "" {
queryDelići += "&pretraga=" + pretraga
}
if tipFilter != "" {
queryDelići += "&tip=" + tipFilter
}
stranicaPrev := stranicaBr - 1
if stranicaPrev < 1 {
stranicaPrev = 1
}
stranicaNext := stranicaBr + 1
if stranicaNext > ukupnoStranica {
stranicaNext = ukupnoStranica
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "klijenti"
ps.NaslovStranice = "Klijenti"
podaci := PodaciKlijenata{
PodaciStranice: ps,
Klijenti: klijenti,
Pretraga: pretraga,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1",
PodaciStranice: ps,
Klijenti: klijenti,
Pretraga: pretraga,
TipFilter: tipFilter,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1",
StranicaBr: stranicaBr,
UkupnoStranica: ukupnoStranica,
UkupnoKlijenata: ukupno,
StranicaPrev: stranicaPrev,
StranicaNext: stranicaNext,
StranicaQueryUrl: queryDelići,
}
h.renderujTemplate(w, "klijenti", podaci)
+171 -21
View File
@@ -1,6 +1,8 @@
package handler
import (
"errors"
"log/slog"
"net/http"
"strconv"
@@ -14,13 +16,23 @@ import (
// PodaciMagacina su podaci za stranicu magacina
type PodaciMagacina struct {
model.PodaciStranice
Artikli []model.ArtikalSaKategorijom
Kategorije []model.Kategorija
Filter db.ArtikalFilter
KategorijaIDStr string
Sacuvano bool
Obrisan bool
Premesten bool
Artikli []model.ArtikalSaKategorijom
Kategorije []model.Kategorija
Filter db.ArtikalFilter
KategorijaIDStr string
Sacuvano bool
Obrisan bool
Arhiviran bool
Vracen bool
Greska bool
PrikazArhivirani bool // true → lista prikazuje arhivirane umesto aktivnih
Premesten bool
StranicaBr int
UkupnoStranica int
UkupnoArtikala int
StranicaPrev int
StranicaNext int
StranicaQueryUrl string // čuva filtere za linkove paginacije
}
// Magacin renderuje listu artikala
@@ -34,6 +46,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
filter := db.ArtikalFilter{
Pretraga: r.URL.Query().Get("pretraga"),
SamoKriticni: r.URL.Query().Get("kriticni") == "1",
Arhivirani: r.URL.Query().Get("arhivirani") == "1",
}
katIDStr := ""
@@ -45,12 +58,30 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
}
}
const pageSize = 50
stranicaBr := 1
if p := r.URL.Query().Get("stranica"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 {
stranicaBr = v
}
}
filter.Limit = pageSize
filter.Offset = (stranicaBr - 1) * pageSize
artikli, err := h.Artikli.Lista(r.Context(), filter)
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
}
ukupno, err := h.Artikli.PrebrojiPoFilteru(r.Context(), filter)
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
}
ukupnoStranica := (ukupno + pageSize - 1) / pageSize
kategorije, err := h.KategorijeRepo.Lista(r.Context())
if err != nil {
http.Error(w, "Greška pri učitavanju kategorija", http.StatusInternalServerError)
@@ -60,15 +91,50 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "magacin"
ps.NaslovStranice = "Magacin"
// izgradi query string za paginaciju (čuva filtere)
queryDelići := ""
if v := filter.Pretraga; v != "" {
queryDelići += "&pretraga=" + v
}
if katIDStr != "" {
queryDelići += "&kategorija=" + katIDStr
}
if filter.SamoKriticni {
queryDelići += "&kriticni=1"
}
if filter.Arhivirani {
queryDelići += "&arhivirani=1"
}
stranicaPrev := stranicaBr - 1
if stranicaPrev < 1 {
stranicaPrev = 1
}
stranicaNext := stranicaBr + 1
if stranicaNext > ukupnoStranica {
stranicaNext = ukupnoStranica
}
podaci := PodaciMagacina{
PodaciStranice: ps,
Artikli: artikli,
Kategorije: kategorije,
Filter: filter,
KategorijaIDStr: katIDStr,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1",
Premesten: r.URL.Query().Get("premesten") == "1",
PodaciStranice: ps,
Artikli: artikli,
Kategorije: kategorije,
Filter: filter,
KategorijaIDStr: katIDStr,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1",
Arhiviran: r.URL.Query().Get("arhiviran") == "1",
Vracen: r.URL.Query().Get("vracen") == "1",
Greska: r.URL.Query().Get("greska") == "1",
PrikazArhivirani: filter.Arhivirani,
Premesten: r.URL.Query().Get("premesten") == "1",
StranicaBr: stranicaBr,
UkupnoStranica: ukupnoStranica,
UkupnoArtikala: ukupno,
StranicaPrev: stranicaPrev,
StranicaNext: stranicaNext,
StranicaQueryUrl: queryDelići,
}
h.renderujTemplate(w, "magacin", podaci)
@@ -117,18 +183,48 @@ func (h *Handler) ObrisiArtikal(w http.ResponseWriter, r *http.Request) {
}
if err := h.Artikli.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju artikla", http.StatusInternalServerError)
// artikal je u prometu — ne brišemo ga, već ga arhiviramo
if errors.Is(err, db.ErrArtikalUUpotrebi) {
if err := h.Artikli.Arhiviraj(r.Context(), id); err != nil {
http.Redirect(w, r, "/magacin?greska=1", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/magacin?arhiviran=1", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/magacin?greska=1", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/magacin?obrisan=1", http.StatusSeeOther)
}
// VratiArtikal poništava arhiviranje i vraća artikal u aktivnu listu
func (h *Handler) VratiArtikal(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.obrisi"); !ok {
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
return
}
if err := h.Artikli.Vrati(r.Context(), id); err != nil {
http.Redirect(w, r, "/magacin?arhivirani=1&greska=1", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/magacin?arhivirani=1&vracen=1", http.StatusSeeOther)
}
// PodaciMagacinskeKartice su podaci za karticu jednog artikla
type PodaciMagacinskeKartice struct {
model.PodaciStranice
Artikal model.Artikal
Promene []model.MagacinskaPromenaSaDetaljem
Artikal model.Artikal
Promene []model.MagacinskaPromenaSaDetaljem
Dobavljaci []model.Dobavljac // dobavljači vezani za artikal
DostupniDobavljaci []model.Dobavljac // dobavljači koji još nisu vezani (za dodavanje)
}
// MagacinskaKartica prikazuje sve promene stanja za jedan artikal
@@ -157,13 +253,67 @@ func (h *Handler) MagacinskaKartica(w http.ResponseWriter, r *http.Request) {
return
}
// dobavljači: vezani za artikal i oni koji još nisu vezani (za padajući izbor)
sviDobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
vezaniIDs, _ := h.Artikli.DobavljaciArtikla(r.Context(), id)
vezanSet := map[int64]bool{}
for _, did := range vezaniIDs {
vezanSet[did] = true
}
var vezani, dostupni []model.Dobavljac
for _, d := range sviDobavljaci {
if vezanSet[d.ID] {
vezani = append(vezani, d)
} else {
dostupni = append(dostupni, d)
}
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "magacin"
ps.NaslovStranice = "Kartica: " + artikal.Naziv
h.renderujTemplate(w, "magacin_kartica", PodaciMagacinskeKartice{
PodaciStranice: ps,
Artikal: *artikal,
Promene: promene,
PodaciStranice: ps,
Artikal: *artikal,
Promene: promene,
Dobavljaci: vezani,
DostupniDobavljaci: dostupni,
})
}
// DodajDobavljacaArtiklu veže izabranog dobavljača za artikal
func (h *Handler) DodajDobavljacaArtiklu(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
return
}
dobID, err := strconv.ParseInt(r.FormValue("dobavljac_id"), 10, 64)
if err != nil {
http.Redirect(w, r, "/magacin/kartica/"+chi.URLParam(r, "id"), http.StatusSeeOther)
return
}
if e := h.Artikli.PoveziDobavljaca(r.Context(), id, dobID); e != nil {
slog.Error("vezivanje dobavljača nije uspelo", "artikal_id", id, "error", e)
}
http.Redirect(w, r, "/magacin/kartica/"+chi.URLParam(r, "id"), http.StatusSeeOther)
}
// ObrisiDobavljacaArtikla uklanja vezu dobavljača sa artiklom
func (h *Handler) ObrisiDobavljacaArtikla(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
return
}
dobID, err := strconv.ParseInt(r.FormValue("dobavljac_id"), 10, 64)
if err != nil {
http.Redirect(w, r, "/magacin/kartica/"+chi.URLParam(r, "id"), http.StatusSeeOther)
return
}
if e := h.Artikli.OdveziDobavljaca(r.Context(), id, dobID); e != nil {
slog.Error("uklanjanje dobavljača nije uspelo", "artikal_id", id, "error", e)
}
http.Redirect(w, r, "/magacin/kartica/"+chi.URLParam(r, "id"), http.StatusSeeOther)
}
+150 -33
View File
@@ -5,6 +5,8 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
@@ -16,11 +18,13 @@ import (
// PodaciFormeArtikla su podaci za formu novog/izmenjenog artikla
type PodaciFormeArtikla struct {
model.PodaciStranice
Artikal model.Artikal
Kategorije []model.Kategorija
KategorijaIDStr string
Greska string
Izmena bool
Artikal model.Artikal
Kategorije []model.Kategorija
KategorijaIDStr string
Dobavljaci []model.Dobavljac // svi dobavljači za izbor
IzabraniDobavljaci map[int64]bool // dobavljači vezani za artikal (za checked stanje)
Greska string
Izmena bool
}
// NoviArtikal prikazuje formu za unos novog artikla
@@ -37,19 +41,22 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
return
}
predlogSifre, err := h.Artikli.SledecaSifra(r.Context())
predlogSifre, err := h.Artikli.SledecaSifra(r.Context(), nil)
if err != nil {
slog.Error("greška pri generisanju predloga šifre", "err", err)
predlogSifre = "ART-00001"
predlogSifre = "ART-0001"
}
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "magacin"
ps.NaslovStranice = "Novi artikal"
h.renderujFormuArtikla(w, PodaciFormeArtikla{
PodaciStranice: ps,
Kategorije: kategorije,
Artikal: model.Artikal{Sifra: predlogSifre},
Dobavljaci: dobavljaci,
Artikal: model.Artikal{Sifra: predlogSifre, Tip: model.TipProizvod, JedinicaMere: "kom"},
Izmena: false,
})
}
@@ -70,6 +77,7 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
katIDStr := ""
if artikal.KategorijaID != nil {
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
@@ -78,30 +86,39 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
ps.Stranica = "magacin"
ps.NaslovStranice = "Novi artikal"
h.renderujFormuArtikla(w, PodaciFormeArtikla{
PodaciStranice: ps,
Artikal: artikal,
Kategorije: kategorije,
KategorijaIDStr: katIDStr,
Greska: greska,
Izmena: false,
PodaciStranice: ps,
Artikal: artikal,
Kategorije: kategorije,
KategorijaIDStr: katIDStr,
Dobavljaci: dobavljaci,
IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)),
Greska: greska,
Izmena: false,
})
return
}
// ako korisnik nije uneo šifru, auto-generišemo pre Kreiraj
// (tako je dodela šifre atomična: ako INSERT padne na UNIQUE constraint,
// Kreiraj vraća grešku umesto da šifra ostane NULL bez ikakve poruke)
if artikal.Sifra == "" {
autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID)
if e != nil {
autoSifra = fmt.Sprintf("ART-%04d", time.Now().UnixMilli()%10000)
}
artikal.Sifra = autoSifra
}
id, err := h.Artikli.Kreiraj(r.Context(), &artikal)
if err != nil {
http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError)
return
}
artikal.ID = id
// ako korisnik nije uneo šifru, auto-generišemo po ID-u
if artikal.Sifra == "" {
autoSifra := fmt.Sprintf("ART-%05d", id)
artikal.ID = id
artikal.Sifra = autoSifra
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
slog.Error("greška pri upisu auto-šifre", "id", id, "err", err)
}
// veži izabrane dobavljače (forma); modal nema to polje pa ostaje prazno
if e := h.Artikli.PostaviDobavljaceArtikla(r.Context(), id, citajDobavljaceForme(r)); e != nil {
slog.Error("čuvanje dobavljača artikla nije uspelo", "artikal_id", id, "error", e)
}
// fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla
@@ -146,15 +163,25 @@ func (h *Handler) IzmeniArtikal(w http.ResponseWriter, r *http.Request) {
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
}
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
izabrani := map[int64]bool{}
if ids, e := h.Artikli.DobavljaciArtikla(r.Context(), id); e == nil {
for _, did := range ids {
izabrani[did] = true
}
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "magacin"
ps.NaslovStranice = "Izmeni artikal"
h.renderujFormuArtikla(w, PodaciFormeArtikla{
PodaciStranice: ps,
Artikal: *artikal,
Kategorije: kategorije,
KategorijaIDStr: katIDStr,
Izmena: true,
PodaciStranice: ps,
Artikal: *artikal,
Kategorije: kategorije,
KategorijaIDStr: katIDStr,
Dobavljaci: dobavljaci,
IzabraniDobavljaci: izabrani,
Izmena: true,
})
}
@@ -181,6 +208,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
artikal.ID = id
katIDStr := ""
if artikal.KategorijaID != nil {
@@ -190,12 +218,14 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
ps.Stranica = "magacin"
ps.NaslovStranice = "Izmeni artikal"
h.renderujFormuArtikla(w, PodaciFormeArtikla{
PodaciStranice: ps,
Artikal: artikal,
Kategorije: kategorije,
KategorijaIDStr: katIDStr,
Greska: greska,
Izmena: true,
PodaciStranice: ps,
Artikal: artikal,
Kategorije: kategorije,
KategorijaIDStr: katIDStr,
Dobavljaci: dobavljaci,
IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)),
Greska: greska,
Izmena: true,
})
return
}
@@ -204,6 +234,13 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
var staraCena float64
if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil {
staraCena = stari.ProdajnaCena
// spreči promenu tipa sa proizvoda na uslugu/trošak ako artikal ima zalihu
if stari.PratiLager() && stari.Kolicina > 0 && !artikal.PratiLager() {
middleware.SetFlash(w, r, h.DB, "greska",
fmt.Sprintf("Artikal ima %d %s na stanju. Prvo koriguj količinu na 0 pre promene tipa.", stari.Kolicina, stari.JedinicaMere))
http.Redirect(w, r, "/magacin/izmeni/"+idStr, http.StatusSeeOther)
return
}
}
artikal.ID = id
@@ -212,6 +249,11 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
return
}
// ažuriraj dobavljače artikla prema formi
if e := h.Artikli.PostaviDobavljaceArtikla(r.Context(), id, citajDobavljaceForme(r)); e != nil {
slog.Error("čuvanje dobavljača artikla nije uspelo", "artikal_id", id, "error", e)
}
// ako se prodajna cena promenila, automatski upiši nivelacioni zapis (izvor "izmena")
if razlika := artikal.ProdajnaCena - staraCena; razlika > 0.005 || razlika < -0.005 {
korisnikID := &k.ID
@@ -229,6 +271,26 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther)
}
// mapaDobavljaca pretvara listu ID-jeva u mapu za checked stanje u formi
func mapaDobavljaca(ids []int64) map[int64]bool {
m := make(map[int64]bool, len(ids))
for _, id := range ids {
m[id] = true
}
return m
}
// citajDobavljaceForme čita izabrane dobavljače (checkbox dobavljaci[]) iz forme
func citajDobavljaceForme(r *http.Request) []int64 {
var ids []int64
for _, v := range r.Form["dobavljaci"] {
if id, e := strconv.ParseInt(strings.TrimSpace(v), 10, 64); e == nil {
ids = append(ids, id)
}
}
return ids
}
// parseFormuArtikla čita polja iz forme i vraća artikal i eventualnu grešku
func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
naziv := r.FormValue("naziv")
@@ -244,6 +306,19 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
artikal.Lokacija = r.FormValue("lokacija")
artikal.Napomena = r.FormValue("napomena")
// tip artikla — podrazumevano proizvod; usluga i trošak ne prate lager
switch r.FormValue("tip") {
case model.TipUsluga:
artikal.Tip = model.TipUsluga
case model.TipTrosak:
artikal.Tip = model.TipTrosak
default:
artikal.Tip = model.TipProizvod
}
// jedinica mere — normalizacija: mala slova, samo slova/brojevi, max 4 karaktera
artikal.JedinicaMere = normalizujJM(r.FormValue("jedinica_mere"))
if k := r.FormValue("kolicina"); k != "" {
v, err := strconv.Atoi(k)
if err != nil || v < 0 {
@@ -260,6 +335,12 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
artikal.KolicinMin = v
}
// usluge i troškovi nemaju stanje na lageru
if !artikal.PratiLager() {
artikal.Kolicina = 0
artikal.KolicinMin = 0
}
if c := r.FormValue("nabavna_cena"); c != "" {
v, err := strconv.ParseFloat(c, 64)
if err != nil || v < 0 {
@@ -295,7 +376,43 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
return artikal, ""
}
// PredlogSifre vraća predlog auto-šifre za izabranu kategoriju (poziva forma pri promeni kategorije)
func (h *Handler) PredlogSifre(w http.ResponseWriter, r *http.Request) {
var kategorijaID *int64
if v := r.URL.Query().Get("kategorija"); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
kategorijaID = &id
}
}
sifra, err := h.Artikli.SledecaSifra(r.Context(), kategorijaID)
if err != nil {
http.Error(w, "Greška pri generisanju šifre", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(sifra))
}
// renderujFormuArtikla renderuje HTML formu za artikal
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
h.renderujTemplate(w, "magacin_forma", podaci)
}
// normalizujJM čisti jedinicu mere: mala slova, samo slova i brojevi, max 4 karaktera.
// Ako je rezultat prazan, vraća "kom" kao podrazumevanu vrednost.
func normalizujJM(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
if b.Len() >= 4 {
break
}
}
}
if b.Len() == 0 {
return "kom"
}
return b.String()
}
+24 -4
View File
@@ -46,19 +46,27 @@ type PodaciDetaljiNabavke struct {
}
// artikalUJSON pretvara listu artikala u template.JS vrednost bezbednu za umetanje u <script> tag
func artikalUJSON(artikli []model.ArtikalSaKategorijom) template.JS {
func artikalUJSON(artikli []model.ArtikalSaKategorijom, vezeDobavljaca map[int64][]int64) template.JS {
type stavka struct {
ID int64 `json:"id"`
Naziv string `json:"naziv"`
PdvStopa float64 `json:"pdv_stopa"`
NabavnaCena float64 `json:"nabavna_cena"` // poslednja nabavna cena — predlog za Cena/kom
Marza *float64 `json:"marza"` // marža artikla; null = nije postavljeno
KategorijaMarza *float64 `json:"kategorija_marza"` // marža kategorije; fallback ako artikal nema
Dobavljaci []int64 `json:"dobavljaci"` // ID-jevi dobavljača koji isporučuju artikal
}
lista := make([]stavka, 0, len(artikli))
for _, a := range artikli {
dob := vezeDobavljaca[a.ID]
if dob == nil {
dob = []int64{}
}
lista = append(lista, stavka{
ID: a.ID, Naziv: a.Naziv, PdvStopa: a.PdvStopa,
Marza: a.Marza, KategorijaMarza: a.KategorijaMarza,
NabavnaCena: a.NabavnaCena,
Marza: a.Marza, KategorijaMarza: a.KategorijaMarza,
Dobavljaci: dob,
})
}
b, _ := json.Marshal(lista)
@@ -118,13 +126,15 @@ func (h *Handler) NovaNabavka(w http.ResponseWriter, r *http.Request) {
return
}
veze, _ := h.Artikli.SveDobavljaceArtikala(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "nabavke"
ps.NaslovStranice = "Nova nabavka"
h.renderujFormuNabavke(w, PodaciFormeNabavke{
PodaciStranice: ps,
Artikli: artikli,
ArtikliJSON: artikalUJSON(artikli),
ArtikliJSON: artikalUJSON(artikli, veze),
Dobavljaci: dobavljaci,
Kategorije: kategorije,
Marza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
@@ -149,13 +159,14 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
artikli, _ := h.Artikli.Lista(r.Context(), db.ArtikalFilter{})
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
veze, _ := h.Artikli.SveDobavljaceArtikala(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "nabavke"
ps.NaslovStranice = "Nova nabavka"
h.renderujFormuNabavke(w, PodaciFormeNabavke{
PodaciStranice: ps,
Artikli: artikli,
ArtikliJSON: artikalUJSON(artikli),
ArtikliJSON: artikalUJSON(artikli, veze),
Dobavljaci: dobavljaci,
Kategorije: kategorije,
Marza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
@@ -241,6 +252,15 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
}
}
// auto-veza artikaldobavljač: svaki nabavljeni artikal se veže za dobavljača nabavke
if nabavka.DobavljacID != nil {
for _, s := range stavke {
if e := h.Artikli.PoveziDobavljaca(r.Context(), s.ArtikalID, *nabavka.DobavljacID); e != nil {
slog.Error("auto-veza dobavljača nije upisana", "artikal_id", s.ArtikalID, "error", e)
}
}
}
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
+3 -2
View File
@@ -93,10 +93,11 @@ func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) {
switch {
case errors.Is(err, sqlite.ErrArtikalNePostoji):
middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
case err != nil:
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri promeni cene.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
default:
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajna cena je izmenjena.")
http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther)
}
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
}
+2 -4
View File
@@ -169,8 +169,7 @@ func (h *Handler) SacuvajPdvKir(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Izlazni račun je dodat u KIR.")
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kir?sacuvano=1", http.StatusSeeOther)
}
// ObrisiPdvKir briše zapis iz KIR
@@ -187,6 +186,5 @@ func (h *Handler) ObrisiPdvKir(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KIR.")
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kir?obrisan=1", http.StatusSeeOther)
}
+2 -4
View File
@@ -154,8 +154,7 @@ func (h *Handler) SacuvajPdvKpr(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Ulazni račun je dodat u KPR.")
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kpr?sacuvano=1", http.StatusSeeOther)
}
// ObrisiPdvKpr briše zapis iz KPR
@@ -172,6 +171,5 @@ func (h *Handler) ObrisiPdvKpr(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KPR.")
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
http.Redirect(w, r, "/pdv/kpr?obrisan=1", http.StatusSeeOther)
}
+3 -10
View File
@@ -99,8 +99,7 @@ func (h *Handler) DodajPdvStopu(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čuvanju PDV stope", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je dodata.")
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
// IzmeniPdvStopu prima POST i menja postojeću stopu
@@ -128,8 +127,7 @@ func (h *Handler) IzmeniPdvStopu(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri izmeni PDV stope", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je izmenjena.")
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
// PromeniAktivnostPdvStope arhivira ili vraća stopu u upotrebu (toggle, bez brisanja)
@@ -151,10 +149,5 @@ func (h *Handler) PromeniAktivnostPdvStope(w http.ResponseWriter, r *http.Reques
http.Error(w, "Greška pri promeni statusa PDV stope", http.StatusInternalServerError)
return
}
poruka := "PDV stopa je arhivirana."
if !postojeca.Aktivna {
poruka = "PDV stopa je vraćena u upotrebu."
}
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
}
+42 -25
View File
@@ -23,13 +23,13 @@ import (
// PodaciPodesavanja su podaci za stranicu podešavanja
type PodaciPodesavanja struct {
model.PodaciStranice
NazivFirme string
Podnazlov string
Adresa string
Telefon string
PIB string
LogoPutanja string
TopbarLogoSlika bool
NazivFirme string
Podnazlov string
Adresa string
Telefon string
PIB string
LogoPutanja string
TopbarLogoSlika bool
// profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale
FirmaPravniOblik string
FirmaPdvObveznik string
@@ -44,6 +44,7 @@ type PodaciPodesavanja struct {
BackupBrojKopija string
KalkulacijaMarza string
ServisGarancijaMeseci string
PredracunRokDana string
LoginPozadina string
LoginPozadinaOpacity string
LoginPozadinaBlurPozadine string
@@ -266,8 +267,11 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
// whitelist dozvoljenih redirekcija — vrednost uvek dolazi iz mape, nikad od korisnika
dozvoljeniSledeci := map[string]string{
"/admin/podesavanja/opste": "/admin/podesavanja/opste",
"/podesavanja": "/podesavanja",
"/admin/podesavanja/opste": "/admin/podesavanja/opste",
"/admin/podesavanja/sistem": "/admin/podesavanja/sistem",
"/admin/podesavanja/servis": "/admin/podesavanja/servis",
"/admin/podesavanja/kalkulacija-pdv": "/admin/podesavanja/kalkulacija-pdv",
"/podesavanja": "/podesavanja",
}
sledeci := "/podesavanja"
if v, ok := dozvoljeniSledeci[r.FormValue("_next")]; ok {
@@ -329,6 +333,20 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
}
}
// rok važenja predračuna u danima (190)
if v := strings.TrimSpace(r.FormValue("predracun_rok_dana")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 90 {
middleware.SetFlash(w, r, h.DB, "greska", "Rok važenja predračuna mora biti broj između 1 i 90 dana.")
http.Redirect(w, r, sledeci, http.StatusSeeOther)
return
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "predracun_rok_dana", strconv.Itoa(n)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, sledeci+"?sacuvano=1", http.StatusSeeOther)
}
@@ -359,20 +377,20 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
// ograničavamo telo zahteva na 2MB + malo za zaglavlja forme
r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096)
if err := r.ParseMultipartForm(2 << 20); err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
return
}
fajl, zaglavlje, err := r.FormFile("logo")
if err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Nije+odabran+fajl", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Nije+odabran+fajl", http.StatusSeeOther)
return
}
defer fajl.Close()
// eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera)
if zaglavlje.Size > 2<<20 {
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
return
}
@@ -386,7 +404,7 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
}
ocekivaniMime, ok := dozvoljenoExt[ext]
if !ok {
http.Redirect(w, r, "/podesavanja?logo_greska=Dozvoljeni+formati+su+PNG%2C+JPG+i+SVG", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Dozvoljeni+formati+su+PNG%2C+JPG+i+SVG", http.StatusSeeOther)
return
}
@@ -396,12 +414,12 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
n, _ := fajl.Read(buf)
stvarniMime := http.DetectContentType(buf[:n])
if !strings.HasPrefix(stvarniMime, ocekivaniMime) {
http.Redirect(w, r, "/podesavanja?logo_greska=Sadržaj+fajla+ne+odgovara+odabranoj+ekstenziji", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Sadržaj+fajla+ne+odgovara+odabranoj+ekstenziji", http.StatusSeeOther)
return
}
// vraćamo kursor na početak
if _, err := fajl.Seek(0, io.SeekStart); err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+obradi+fajla", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+obradi+fajla", http.StatusSeeOther)
return
}
}
@@ -416,14 +434,14 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
dst, err := os.Create(odrediste)
if err != nil {
slog.Error("upload loga: ne mogu kreirati fajl", "error", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
return
}
defer dst.Close()
if _, err := io.Copy(dst, fajl); err != nil {
slog.Error("upload loga: greška pri kopiranju", "error", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
return
}
@@ -431,11 +449,11 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
putanja := fmt.Sprintf("/static/uploads/logo%s?v=%d", ext, time.Now().Unix())
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
slog.Error("upload loga: greška pri čuvanju putanje", "error", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther)
}
// UkloniLogo briše logo fajl i čisti putanju iz podešavanja
@@ -536,7 +554,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) {
staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if stara := staraPodesavanja["login_pozadina"]; stara != "" {
// putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla
deoBezverzije := strings.Split(stara, "?")[0]
deoBezverzije, _, _ := strings.Cut(stara, "?")
staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme))
}
@@ -587,7 +605,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err == nil {
if stara := podesavanja["login_pozadina"]; stara != "" {
deoBezverzije := strings.Split(stara, "?")[0]
deoBezverzije, _, _ := strings.Cut(stara, "?")
staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme))
}
@@ -600,8 +618,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.")
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
}
// SacuvajLoginPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine login stranice
@@ -659,8 +676,7 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req
}
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Izgled pozadine prijave je sačuvan.")
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
}
// napuniPodaciPodesavanja učitava sva podešavanja i kreira strukturu za template
@@ -699,6 +715,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"),
PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"),
}, nil
}
+2
View File
@@ -98,6 +98,8 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme)
if err != nil {
// izjednačavamo vreme odgovora sa slučajem postojećeg korisnika (anti-enumeracija)
auth.IzjednaciVremeProvere(lozinka)
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false)
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "korisnik_ne_postoji", false)
auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_user")
+3 -3
View File
@@ -325,13 +325,14 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok {
return
}
k := middleware.KorisnikIzKonteksta(r.Context())
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil {
if err := h.ProdajaRepo.Obrisi(r.Context(), id, &k.ID); err != nil {
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
return
}
@@ -432,8 +433,7 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
+67 -35
View File
@@ -15,6 +15,22 @@ import (
"ntech/internal/middleware"
)
// sacuvanoReferer vraća URL za redirect posle uspešnog čuvanja:
// koristi Referer zaglavlje (samo putanja, nikad spoljni sajt) i dodaje ?sacuvano=1.
// AJAX handler u browseru detektuje sacuvano=1 i prikazuje toast bez reloada.
func sacuvanoReferer(r *http.Request, rezervna string) string {
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil {
putanja := u.RequestURI()
if strings.Contains(putanja, "?") {
return putanja + "&sacuvano=1"
}
return putanja + "?sacuvano=1"
}
}
return rezervna + "?sacuvano=1"
}
// ProfilTema prikazuje stranicu lične teme i pozadine
func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
@@ -177,8 +193,7 @@ func (h *Handler) ProfilOtpremiPozadinu(w http.ResponseWriter, r *http.Request)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
// ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika
@@ -202,8 +217,7 @@ func (h *Handler) ProfilUkloniPozadinu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
// ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine
@@ -250,8 +264,7 @@ func (h *Handler) ProfilSacuvajPozadinuStilove(w http.ResponseWriter, r *http.Re
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Podešavanja su sačuvana.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
// SacuvajLokalnuTemu čuva korisnikovu lokalnu temu
@@ -284,15 +297,7 @@ func (h *Handler) SacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Tema je sačuvana.")
// koristimo samo putanju iz Referer zaglavlja — nikad ceo URL jer može biti spoljni sajt
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
return
}
}
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil"), http.StatusSeeOther)
}
// SacuvajLokalnuAnimaciju čuva korisnikovu preferencu animacije tabela
@@ -313,25 +318,29 @@ func (h *Handler) SacuvajLokalnuAnimaciju(w http.ResponseWriter, r *http.Request
}
animacija := r.FormValue("lokalna_animacija")
dozvoljene := map[string]bool{"": true, "bez": true, "fadeInUp": true, "fadeIn": true, "scaleIn": true, "slideLeft": true}
dozvoljene := map[string]bool{"": true, "bez": true, "fadeInUp": true, "fadeIn": true, "blurIn": true, "slideLeft": true}
if !dozvoljene[animacija] {
animacija = ""
}
brzina := r.FormValue("lokalna_brzina_animacije")
dozvoljenieBrzine := map[string]bool{"": true, "0.1": true, "0.2": true, "0.3": true, "0.4": true, "0.5": true, "0.6": true, "0.7": true, "0.8": true}
if !dozvoljenieBrzine[brzina] {
brzina = ""
}
if err := h.KorisniciRepo.SacuvajLokalnuAnimaciju(r.Context(), k.ID, animacija); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Animacija je sačuvana.")
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
return
}
if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
}
// SacuvajLokalniHover čuva korisnikovu preferencu hover efekta na karticama
@@ -363,14 +372,39 @@ func (h *Handler) SacuvajLokalniHover(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Hover efekat je sačuvan.")
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
return
}
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
}
// SacuvajLokalnuBrzinuAnimacije čuva korisnikovu preferencu brzine animacije tabela
func (h *Handler) SacuvajLokalnuBrzinuAnimacije(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") {
http.Error(w, "Pristup odbijen", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return
}
brzina := r.FormValue("lokalna_brzina_animacije")
dozvoljene := map[string]bool{"": true, "0.2": true, "0.4": true, "0.6": true, "0.8": true, "1.0": true, "1.2": true, "1.5": true}
if !dozvoljene[brzina] {
brzina = ""
}
if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return
}
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
}
// ProfilOtpremiAvatar prima upload lične avatar slike korisnika
@@ -464,8 +498,7 @@ func (h *Handler) ProfilOtpremiAvatar(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je sačuvan.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
// ProfilUkloniAvatar briše ličnu avatar sliku korisnika
@@ -489,6 +522,5 @@ func (h *Handler) ProfilUkloniAvatar(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je uklonjen.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
}
+196 -16
View File
@@ -444,8 +444,7 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je dodat.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga
@@ -470,10 +469,7 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
slog.Error("greška pri brisanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.")
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je uklonjen.")
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
}
@@ -647,12 +643,8 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
ukupnoDelovi += d.Ukupno()
}
// QR kod sadrži URL naloga — isti host kao što korisnik koristi
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
nalogURL := scheme + "://" + r.Host + "/servis/" + strconv.FormatInt(id, 10)
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
nalogURL := qrNalogURL(r, nalog.JavniToken)
var qrKod string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKod = base64.StdEncoding.EncodeToString(png)
@@ -758,11 +750,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
}
}
nalogURL := "http"
if r.TLS != nil {
nalogURL += "s"
}
nalogURL += "://" + r.Host + "/servis/" + strconv.FormatInt(id, 10)
nalogURL := qrNalogURL(r, nalog.JavniToken)
var qrKodOtpr string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKodOtpr = base64.StdEncoding.EncodeToString(png)
@@ -786,6 +774,150 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
})
}
// PodaciPredracuna su podaci za predračun/ponudu koja se šalje klijentu pre rada
type PodaciPredracuna struct {
Nalog model.ServisniNalog
ServisniDelovi []model.ServisniDeoSaArtiklom
UkupnoDelovi float64
ImaCenuRada bool // ima li nalog uopšte cenu rada (raspon ili fiksnu)
CenaRaspon bool // true → prikaži procenu OdDo; false → fiksnu cenu
CenaRadaOd float64 // donja granica procene
CenaRadaDo float64 // gornja granica procene
CenaRada float64 // fiksna cena rada
UkupnoOd float64 // delovi + CenaRadaOd (kad je raspon)
UkupnoDo float64 // delovi + CenaRadaDo (kad je raspon)
Ukupno float64 // delovi + fiksna cena (ili samo delovi ako nema cene rada)
DatumIzdavanja time.Time
VaziDo time.Time
QRKod string
Klijent *model.Klijent
KlijentNaziv string
TehnicarNaziv string
NazivFirme string
Podnazlov string
Adresa string
Telefon string
PIB string
}
// StampaPredracuna renderuje predračun/ponudu koja se šalje klijentu kada se utvrdi kvar
func (h *Handler) StampaPredracuna(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
nalog, err := h.ServisRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "Nalog nije pronađen", http.StatusNotFound)
return
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
return
}
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
var klijent *model.Klijent
klijentNaziv := ""
if nalog.KlijentID != nil {
k, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
if err == nil {
klijent = k
if k.NazivFirme != "" {
klijentNaziv = k.NazivFirme
} else {
klijentNaziv = strings.TrimSpace(k.Ime + " " + k.Prezime)
}
}
}
tehnicarNaziv := ""
if nalog.TehnicarID != nil {
tehnicar, err := h.KorisniciRepo.DohvatiPoID(r.Context(), *nalog.TehnicarID)
if err == nil {
tehnicarNaziv = tehnicar.KorisnickoIme
}
}
var ukupnoDelovi float64
for _, d := range delovi {
ukupnoDelovi += d.Ukupno()
}
// cena rada: ako postoji procena raspona (Od i Do) prikaži je, inače padni na fiksnu cenu
var imaCenuRada, cenaRaspon bool
var cenaRadaOd, cenaRadaDo, cenaRada float64
var ukupnoOd, ukupnoDo, ukupno float64
switch {
case nalog.CenaOd != nil && nalog.CenaDo != nil:
imaCenuRada = true
cenaRaspon = true
cenaRadaOd = *nalog.CenaOd
cenaRadaDo = *nalog.CenaDo
ukupnoOd = ukupnoDelovi + cenaRadaOd
ukupnoDo = ukupnoDelovi + cenaRadaDo
case nalog.CenaKonacna != nil:
imaCenuRada = true
cenaRada = *nalog.CenaKonacna
ukupno = ukupnoDelovi + cenaRada
default:
ukupno = ukupnoDelovi
}
// rok važenja iz podešavanja (default 7 dana)
rok := 7
if v, err := strconv.Atoi(podesavanja["predracun_rok_dana"]); err == nil && v > 0 {
rok = v
}
datumIzdavanja := time.Now()
vaziDo := datumIzdavanja.AddDate(0, 0, rok)
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
nalogURL := qrNalogURL(r, nalog.JavniToken)
var qrKod string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKod = base64.StdEncoding.EncodeToString(png)
}
h.renderujStandalone(w, "servis_predracun", PodaciPredracuna{
Nalog: *nalog,
ServisniDelovi: delovi,
UkupnoDelovi: ukupnoDelovi,
ImaCenuRada: imaCenuRada,
CenaRaspon: cenaRaspon,
CenaRadaOd: cenaRadaOd,
CenaRadaDo: cenaRadaDo,
CenaRada: cenaRada,
UkupnoOd: ukupnoOd,
UkupnoDo: ukupnoDo,
Ukupno: ukupno,
DatumIzdavanja: datumIzdavanja,
VaziDo: vaziDo,
QRKod: qrKod,
Klijent: klijent,
KlijentNaziv: klijentNaziv,
TehnicarNaziv: tehnicarNaziv,
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
Adresa: podesavanja["adresa"],
Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"],
})
}
// PromeniStatus obrađuje POST /servis/{id}/status i menja samo status naloga
func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
@@ -813,3 +945,51 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
type PodaciJavnogStatusa struct {
Nalog model.ServisniNalog
NazivFirme string
Telefon string
Adresa string
SviStatusi []string
}
// ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda
func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
http.NotFound(w, r)
return
}
nalog, err := h.ServisRepo.DohvatiJavniToken(r.Context(), token)
if err != nil {
http.NotFound(w, r)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
Nalog: *nalog,
NazivFirme: podesavanja["naziv_firme"],
Telefon: podesavanja["telefon"],
Adresa: podesavanja["adresa"],
SviStatusi: model.SviStatusi,
})
}
// qrNalogURL konstruiše URL za QR kod vodeći računa o reverse proxy-ju.
// Ako aplikacija radi iza nginx/Caddy/Traefik koji prekida TLS, r.TLS je nil,
// ali X-Forwarded-Proto header sadrži stvarnu šemu.
func qrNalogURL(r *http.Request, token string) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host + "/status/" + token
}
+12
View File
@@ -6,10 +6,17 @@ import (
"encoding/base64"
"net/http"
"os"
"strings"
)
const csrfKolacic = "ntech_csrf"
// maxTeloUpload je gornja granica veličine tela multipart zahteva (upload fajlova).
// Postavlja se u middleware-u pre parsiranja jer čitanje _csrf polja već parsira
// telo — bez ovog limita pojedinačni handleri ne mogu da ga efektivno ograniče.
// Najveći legitiman upload je 5 MB (avatar, pozadina); ostatak je rezerva za form overhead.
const maxTeloUpload = 6 << 20
type csrfKljucTip struct{}
var csrfKljuc = csrfKljucTip{}
@@ -52,6 +59,11 @@ func CsrfMiddleware(next http.Handler) http.Handler {
// validiramo na svim mutabilnim HTTP metodama
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
// ograničavamo veličinu tela PRE parsiranja — čitanje _csrf polja parsira
// celo telo, pa je ovo jedino mesto gde limit za upload stvarno deluje
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
r.Body = http.MaxBytesReader(w, r.Body, maxTeloUpload)
}
// čitamo token iz tela forme ili zaglavlja (za AJAX)
submitted := r.FormValue("_csrf")
if submitted == "" {
+17
View File
@@ -2,6 +2,13 @@ package model
import "time"
// Tipovi artikla. Proizvod prati stanje na lageru; usluga i trošak ga ne prate.
const (
TipProizvod = "proizvod"
TipUsluga = "usluga"
TipTrosak = "trosak"
)
// Artikal predstavlja jedan artikal u magacinu
type Artikal struct {
ID int64
@@ -10,6 +17,8 @@ type Artikal struct {
Barkod string
Naziv string
Opis string
Tip string // proizvod | usluga | trosak
JedinicaMere string // kom, sat, set, m, l, kg ...
Kolicina int
KolicinMin int
Lokacija string
@@ -19,6 +28,13 @@ type Artikal struct {
Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno
Napomena string
DatumUnosa time.Time
Arhiviran bool // artikal u prometu koji je sklonjen iz aktivne liste; istorija ostaje
}
// PratiLager vraća true samo za proizvode (usluge i troškovi nemaju stanje na lageru).
// Prazan tip se tretira kao proizvod radi kompatibilnosti sa starim zapisima.
func (a Artikal) PratiLager() bool {
return a.Tip == TipProizvod || a.Tip == ""
}
// CenaBezPdv izračunava prodajnu cenu bez PDV-a
@@ -36,6 +52,7 @@ type Kategorija struct {
ID int64
Naziv string
Opis string
Kod string // prefiks za šifru artikla (npr. KOMP -> KOMP-0001)
Marza *float64 // podrazumevana marža (%) za artikle ove kategorije; NULL = nije postavljeno
}
+1
View File
@@ -21,6 +21,7 @@ type Korisnik struct {
AvatarPutanja string
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | "0.6" | "0.8" | "1.2" (sekunde)
}
// Sesija predstavlja aktivnu sesiju prijavljenog korisnika
+10 -9
View File
@@ -4,15 +4,15 @@ import "time"
// ProdajniNalog predstavlja zaglavlje jedne prodaje
type ProdajniNalog struct {
ID int64
KlijentID *int64
BrojNaloga string
Napomena string
Ukupno float64
NacinPlacanja string
Stornirano bool
RazlogStorniranja string
Datum time.Time
ID int64
KlijentID *int64
BrojNaloga string
Napomena string
Ukupno float64
NacinPlacanja string
Stornirano bool
RazlogStorniranja string
Datum time.Time
}
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
@@ -38,4 +38,5 @@ type ProdajniNalogSaDetaljem struct {
type StavkaProdajeSaArtiklom struct {
StavkaProdaje
ArtikalNaziv string
JedinicaMere string
}
+1
View File
@@ -46,6 +46,7 @@ type ServisniNalog struct {
Ostecenja string
PinUredjaja string
Pribor string
JavniToken string
}
// ServisniDeo predstavlja jedan artikal ugrađen u servisni nalog
+1
View File
@@ -55,6 +55,7 @@ type PodaciStranice struct {
AppPozadinaGlassOpacity string // vrednost 0-80 (% zatamnjivanje glass elemenata) — samo za ličnu pozadinu
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | ... | "1.5" (sekunde)
}
// PodaciDashboarda su podaci specifični za dashboard stranicu
+1
View File
@@ -0,0 +1 @@
ALTER TABLE korisnici ADD COLUMN lokalna_brzina_animacije TEXT NOT NULL DEFAULT '';
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE servisni_nalozi ADD COLUMN javni_token TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_servisni_nalozi_javni_token ON servisni_nalozi(javni_token);
+4
View File
@@ -0,0 +1,4 @@
-- Arhiviranje artikala: artikal koji je bio u prometu ne može se obrisati (FK RESTRICT),
-- pa se umesto brisanja označava kao arhiviran. Arhiviran artikal se ne nudi za nov promet,
-- ali ostaje vidljiv u istoriji (prodaja, nabavka, servis) i na svojoj kartici.
ALTER TABLE artikli ADD COLUMN arhiviran INTEGER NOT NULL DEFAULT 0;
+2
View File
@@ -0,0 +1,2 @@
-- Kôd kategorije služi kao prefiks šifre artikla (npr. "Komponente" -> KOMP -> KOMP-0001).
ALTER TABLE kategorije ADD COLUMN kod TEXT;
+4
View File
@@ -0,0 +1,4 @@
-- Tip artikla: 'proizvod' (prati lager), 'usluga' i 'trosak' (ne prate lager).
ALTER TABLE artikli ADD COLUMN tip TEXT NOT NULL DEFAULT 'proizvod';
-- Jedinica mere artikla (kom, sat, set, m, l, kg ...).
ALTER TABLE artikli ADD COLUMN jedinica_mere TEXT NOT NULL DEFAULT 'kom';
@@ -0,0 +1,3 @@
UPDATE kategorije
SET kod = UPPER(SUBSTR(REPLACE(naziv, ' ', ''), 1, 4))
WHERE (kod IS NULL OR kod = '') AND naziv IS NOT NULL AND naziv != '';
+76 -15
View File
@@ -5,6 +5,16 @@
padding: 0;
}
:root { --anim-trajanje: 0.4s; }
[data-brzina-animacije="0.1"] { --anim-trajanje: 0.1s; }
[data-brzina-animacije="0.2"] { --anim-trajanje: 0.2s; }
[data-brzina-animacije="0.3"] { --anim-trajanje: 0.3s; }
[data-brzina-animacije="0.4"] { --anim-trajanje: 0.4s; }
[data-brzina-animacije="0.5"] { --anim-trajanje: 0.5s; }
[data-brzina-animacije="0.6"] { --anim-trajanje: 0.6s; }
[data-brzina-animacije="0.7"] { --anim-trajanje: 0.7s; }
[data-brzina-animacije="0.8"] { --anim-trajanje: 0.8s; }
html {
background: var(--pozadina);
}
@@ -324,6 +334,8 @@ body {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.topbar-logo { height: 34px; width: auto; border-radius: 6px; flex-shrink: 0; }
/* sakrivanje preko klase (ne inline display) da mobilni @media koji gasi logo ostane nadređen */
.topbar-logo.skriven { display: none; }
/* sadržaj stranice */
.sadrzaj {
@@ -503,6 +515,9 @@ body {
font-weight: 500;
cursor: pointer;
text-decoration: none;
/* sitna akciona dugmad ne prelamaju tekst inače Promeni cenu" padne u 2 reda
pa to dugme postane više od ostalih u istom redu */
white-space: nowrap;
transition: opacity 0.2s;
}
.btn-primarno-malo:hover { opacity: 0.85; }
@@ -524,6 +539,10 @@ body {
.btn-sekundarno:hover { background: var(--pozadina); color: var(--tekst-glavni); }
/* crveno dugme za brisanje u tabelama */
/* naziv artikla u listi koji vodi na karticu */
.link-naziv { text-decoration: none; }
.link-naziv:hover { color: var(--sb-akcent); text-decoration: underline; }
.btn-obrisi-malo {
display: inline-flex;
align-items: center;
@@ -536,6 +555,7 @@ body {
font-weight: 500;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: opacity 0.2s;
}
.btn-obrisi-malo:hover { opacity: 0.8; }
@@ -793,8 +813,8 @@ select {
animation: toastIn 0.3s ease forwards;
max-width: 340px;
}
.toast-greska { background: rgba(207, 87, 87, 0.12); color: var(--greska); border: 0.5px solid var(--greska); }
.toast-uspeh { background: var(--poruka-uspeh-bg); color: var(--poruka-uspeh-boja); border: 0.5px solid var(--poruka-uspeh-boja); }
.toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; }
.toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; }
.toast.nestaje { animation: toastOut 0.3s ease forwards; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(12px); }
@@ -1011,7 +1031,7 @@ select {
/* animacije */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -1020,13 +1040,13 @@ select {
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
@keyframes blurIn {
from { opacity: 0; filter: blur(8px); }
to { opacity: 1; filter: blur(0); }
}
@keyframes slideLeft {
from { opacity: 0; transform: translateX(-12px); }
from { opacity: 0; transform: translateX(-18px); }
to { opacity: 1; transform: translateX(0); }
}
@@ -1050,7 +1070,7 @@ select {
/* backwards (ne both): drži početni frame tokom stagger-delay-a da nema treperenja,
ali NE zaključava krajnji transform pa .kartica:hover lift radi i na .animiraj karticama */
.animiraj {
animation: fadeInUp 0.2s ease backwards;
animation: fadeInUp var(--anim-trajanje) ease backwards;
}
/* kartice ne animiramo po defaultu — animacija je samo na redovima tabela */
@@ -1058,6 +1078,22 @@ select {
animation: none;
}
/* gasi animaciju redova pri HTMX pretrazi: hx-on:htmx:before-request dodaje ovu klasu
na #magacin-rezultati. Pošto pretraga menja samo innerHTML kontejnera, klasa ostaje
na njemu i kroz naredne zamene, pa novoubačeni redovi ne animiraju. Pun reload
stranice i klik na paginaciju (bez pretrage) ne dodaju klasu, pa animacija ostaje.
ID u selektoru je OBAVEZAN: bez njega specifičnost je jednaka pravilima
[data-animacija="..."] niže u fajlu, koja bi onda nadjačala ovo i animacija bi
se i dalje primenjivala kad korisnik ima izabran stil animacije. */
#magacin-rezultati.bez-anim .tabela tbody tr,
#magacin-rezultati.bez-anim .animiraj { animation: none; }
#klijenti-rezultati.bez-anim .tabela tbody tr,
#klijenti-rezultati.bez-anim .animiraj { animation: none; }
#dobavljaci-rezultati.bez-anim .tabela tbody tr,
#dobavljaci-rezultati.bez-anim .animiraj { animation: none; }
/* korisnikova preferencija animacije: body[data-animacija] nadjačava podrazumevano.
Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */
[data-animacija="bez"] .animiraj,
@@ -1066,26 +1102,26 @@ select {
[data-animacija="fadeInUp"] .animiraj,
[data-animacija="fadeInUp"] .tabela tbody tr,
[data-animacija="fadeInUp"] .kartica.animiraj { animation: fadeInUp 0.2s ease backwards; }
[data-animacija="fadeInUp"] .kartica.animiraj { animation: fadeInUp var(--anim-trajanje) ease backwards; }
[data-animacija="fadeIn"] .animiraj,
[data-animacija="fadeIn"] .tabela tbody tr,
[data-animacija="fadeIn"] .kartica.animiraj { animation: fadeIn 0.2s ease backwards; }
[data-animacija="fadeIn"] .kartica.animiraj { animation: fadeIn var(--anim-trajanje) ease backwards; }
[data-animacija="scaleIn"] .animiraj,
[data-animacija="scaleIn"] .tabela tbody tr,
[data-animacija="scaleIn"] .kartica.animiraj { animation: scaleIn 0.2s ease backwards; }
[data-animacija="blurIn"] .animiraj,
[data-animacija="blurIn"] .tabela tbody tr,
[data-animacija="blurIn"] .kartica.animiraj { animation: blurIn var(--anim-trajanje) ease backwards; }
[data-animacija="slideLeft"] .animiraj,
[data-animacija="slideLeft"] .tabela tbody tr,
[data-animacija="slideLeft"] .kartica.animiraj { animation: slideLeft 0.2s ease backwards; }
[data-animacija="slideLeft"] .kartica.animiraj { animation: slideLeft var(--anim-trajanje) ease backwards; }
/* Stepenasta (stagger) animacija redova u svim listama JEDNO mesto za sve tabele.
Animacija se primenjuje direktno na <tr> u .tabela, bez potrebe za .animiraj klasom.
Mora biti u main.css: HTMX navigacija (hx-select) odbacuje <head>, pa
stilovi iz page <style> blokova ne bi važili tokom navigacije. */
.tabela tbody tr {
animation: fadeInUp 0.2s ease backwards;
animation: fadeInUp var(--anim-trajanje) ease backwards;
}
.tabela tbody tr:nth-child(1) { animation-delay: 0.04s; }
.tabela tbody tr:nth-child(2) { animation-delay: 0.08s; }
@@ -1097,6 +1133,26 @@ select {
.tabela tbody tr:nth-child(8) { animation-delay: 0.32s; }
.tabela tbody tr:nth-child(9) { animation-delay: 0.36s; }
.tabela tbody tr:nth-child(10) { animation-delay: 0.40s; }
.tabela tbody tr:nth-child(11) { animation-delay: 0.44s; }
.tabela tbody tr:nth-child(12) { animation-delay: 0.48s; }
.tabela tbody tr:nth-child(13) { animation-delay: 0.52s; }
.tabela tbody tr:nth-child(14) { animation-delay: 0.56s; }
.tabela tbody tr:nth-child(15) { animation-delay: 0.60s; }
.tabela tbody tr:nth-child(16) { animation-delay: 0.64s; }
.tabela tbody tr:nth-child(17) { animation-delay: 0.68s; }
.tabela tbody tr:nth-child(18) { animation-delay: 0.72s; }
.tabela tbody tr:nth-child(19) { animation-delay: 0.76s; }
.tabela tbody tr:nth-child(20) { animation-delay: 0.80s; }
/* content-visibility: auto browser preskače render elemenata van viewport-a.
Stranica je ograničena na 50 redova, pa redovi tabele ne koriste ovu optimizaciju:
procenjena visina (contain-intrinsic-size) se nije poklapala sa stvarnom, pa su
redovi secali" pri skrolu kad ih browser tek tada izmeri. */
[class*="-kartice"] > .animiraj,
.klijenti-kartice > .animiraj {
content-visibility: auto;
contain-intrinsic-size: 120px;
}
/* Stagger mobilnih lista-kartica (parnjak gornjeg za tabele). Pogađa kartice
unutar bilo kog .X-kartice kontejnera; ostali tipovi kartica (detalji/forma/
@@ -1106,6 +1162,11 @@ select {
[class*="-kartice"] > .animiraj:nth-child(3) { animation-delay: 0.16s; }
[class*="-kartice"] > .animiraj:nth-child(4) { animation-delay: 0.22s; }
[class*="-kartice"] > .animiraj:nth-child(5) { animation-delay: 0.28s; }
[class*="-kartice"] > .animiraj:nth-child(6) { animation-delay: 0.34s; }
[class*="-kartice"] > .animiraj:nth-child(7) { animation-delay: 0.40s; }
[class*="-kartice"] > .animiraj:nth-child(8) { animation-delay: 0.46s; }
[class*="-kartice"] > .animiraj:nth-child(9) { animation-delay: 0.52s; }
[class*="-kartice"] > .animiraj:nth-child(10) { animation-delay: 0.58s; }
/* Stagger naslaganih .kartica.animiraj na stranicama podešavanja/profila JEDNO mesto.
Descendant selektor (razmak, ne >): nth-child se računa po neposrednom roditelju kao i
+3
View File
@@ -22,3 +22,6 @@
--poruka-uspeh-bg: rgba(93, 184, 118, 0.15);
--poruka-uspeh-boja: #5db876;
}
.toast-uspeh { background: #1a2e22; color: #5db876; border: 0.5px solid #2d5a3d; }
.toast-greska { background: #2e1a1a; color: #e07070; border: 0.5px solid #5a2d2d; }
+40 -2
View File
@@ -181,6 +181,13 @@ document.addEventListener('alpine:init', () => {
},
ukupnoSvega() {
return this.stavke.reduce((z, s) => z + (parseFloat(s.kolicina) * parseFloat(s.cena) || 0), 0).toFixed(2)
},
// fmtDin formatira broj sa separatorom hiljada (tačka) i 2 decimale (zarez): 1234567.5 → "1.234.567,50"
fmtDin(v) {
const n = parseFloat(v) || 0
const [ceo, dec] = Math.abs(n).toFixed(2).split('.')
const saTackama = ceo.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
return (n < 0 ? '-' : '') + saTackama + ',' + dec
}
}))
@@ -188,6 +195,8 @@ document.addEventListener('alpine:init', () => {
Alpine.data('nabavkaForma', () => ({
stavke: [{artikal_id: '', kolicina: 1, cena: 0, marza: 0, prodajna: 0}],
artikliOpcije: [],
dobavljacId: '', // izabrani dobavljač nabavke — filtrira listu artikala
prikaziSveArtikle: false, // true = prikaži sve artikle, ne samo dobavljačeve
marzaDefault: 0,
troskovi: [], // zavisni troškovi {naziv, iznos}
metodRaspodele: 'vrednost', // 'vrednost' ili 'kolicina'
@@ -226,9 +235,21 @@ document.addEventListener('alpine:init', () => {
})
},
dodajStavku() {
// ne dozvoli novu stavku dok poslednja nema izabran artikal
const poslednja = this.stavke[this.stavke.length - 1]
if (poslednja && !poslednja.artikal_id) {
if (window.ntechToast) window.ntechToast('Prvo izaberi artikal u poslednjoj stavci.', 'greska')
return
}
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, marza: this.marzaDefault, prodajna: 0})
this.preracunajSve()
},
// artikli za prikaz u stavkama: bez dobavljača (ili uz "prikaži sve") svi, inače samo njegovi
artikliZaDobavljaca() {
if (!this.dobavljacId || this.prikaziSveArtikle) return this.artikliOpcije
const did = Number(this.dobavljacId)
return this.artikliOpcije.filter(a => Array.isArray(a.dobavljaci) && a.dobavljaci.includes(did))
},
// PDV stopa izabranog artikla (iz JSON liste) — za obračun prodajne cene.
// Ako firma nije PDV obveznik, PDV se ne dodaje na prodajnu cenu (stopa = 0).
pdvStopa(artikalId) {
@@ -240,11 +261,13 @@ document.addEventListener('alpine:init', () => {
izaberiArtikal(s) {
const a = this.artikliOpcije.find(x => String(x.id) === String(s.artikal_id))
if (a) {
if (a.nabavna_cena != null) s.cena = a.nabavna_cena
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)
this.preracunajSve()
},
// ukupan zavisni trošak nabavke
ukupanTrosak() {
@@ -297,7 +320,12 @@ document.addEventListener('alpine:init', () => {
this.preracunajSve()
},
ukloniStavku(i) {
if (this.stavke.length > 1) this.stavke.splice(i, 1)
if (this.stavke.length > 1) {
this.stavke.splice(i, 1)
} else {
// poslednja stavka — ne brišemo red nego ga resetujemo na prazno
this.stavke[0] = {artikal_id: '', kolicina: 1, cena: 0, marza: this.marzaDefault, prodajna: 0}
}
this.preracunajSve()
},
ukupnoStavke(s) {
@@ -306,6 +334,13 @@ document.addEventListener('alpine:init', () => {
ukupnoSvega() {
return this.stavke.reduce((z, s) => z + (parseFloat(s.kolicina) * parseFloat(s.cena) || 0), 0).toFixed(2)
},
// fmtDin formatira broj sa separatorom hiljada (tačka) i 2 decimale (zarez): 1234567.5 → "1.234.567,50"
fmtDin(v) {
const n = parseFloat(v) || 0
const [ceo, dec] = Math.abs(n).toFixed(2).split('.')
const saTackama = ceo.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
return (n < 0 ? '-' : '') + saTackama + ',' + dec
},
otvoriModal() {
this.modal = true
this.modalGreska = ''
@@ -352,7 +387,10 @@ document.addEventListener('alpine:init', () => {
return
}
const noviArtikal = await odgovor.json()
this.artikliOpcije.push({id: noviArtikal.id, naziv: noviArtikal.naziv})
// veži novi artikal za izabranog dobavljača da se odmah prikaže u filtriranoj listi
// (veza se trajno upisuje u bazu pri čuvanju nabavke — auto-veza)
const dob = this.dobavljacId ? [Number(this.dobavljacId)] : []
this.artikliOpcije.push({id: noviArtikal.id, naziv: noviArtikal.naziv, dobavljaci: dob})
this.zatvoriModal()
} catch {
this.modalGreska = 'Greška pri komunikaciji sa serverom.'
+2 -2
View File
@@ -10,8 +10,8 @@
</svg>
</button>
{{if and .TopbarLogoSlika .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" class="topbar-logo">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" class="topbar-logo{{if not .TopbarLogoSlika}} skriven{{end}}">
{{end}}
<span class="topbar-naslov">{{.NaslovStranice}}</span>
+2 -2
View File
@@ -50,7 +50,7 @@
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
</div>
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);">{{ printf "%.0f" .PrihodOvogMeseca }} din</div>
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);">{{ dinariCeli .PrihodOvogMeseca }} din</div>
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Prihod ovog meseca</div>
</a>
{{ end }}
@@ -126,7 +126,7 @@
{{ end }}
</div>
<div style="text-align:right;flex-shrink:0;">
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{ printf "%.0f" .Ukupno }} din</div>
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{ dinariCeli .Ukupno }} din</div>
<div style="font-size:11px;color:var(--tekst-sporedni);margin-top:1px;">{{ .Datum }}</div>
</div>
</div>
+36 -11
View File
@@ -12,19 +12,21 @@
<div class="poruka-uspeh poruka-animacija">Dobavljač je uspešno obrisan.</div>
{{end}}
<!-- gornja traka: dugme + pretraga -->
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<!-- gornja traka: dugme + interaktivna pretraga -->
<div id="dobavljaci-filteri" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<a href="/dobavljaci/novi" class="btn-primarno">+ Novi dobavljač</a>
<form method="GET" action="/dobavljaci" style="display:flex;gap:8px;flex:1;min-width:200px;">
<form method="GET" action="/dobavljaci" style="display:flex;gap:8px;flex:1;min-width:200px;"
hx-get="/dobavljaci" hx-target="#dobavljaci-rezultati" hx-select="#dobavljaci-rezultati" hx-swap="innerHTML" hx-push-url="true">
<input type="text" name="pretraga" value="{{.Pretraga}}"
placeholder="Pretraži dobavljače..."
style="flex:1;">
<button type="submit" class="btn-primarno">
Traži
</button>
style="flex:1;"
hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search"
hx-get="/dobavljaci" hx-target="#dobavljaci-rezultati" hx-select="#dobavljaci-rezultati" hx-swap="innerHTML" hx-push-url="true">
</form>
</div>
<div id="dobavljaci-rezultati">
<!-- desktop tabela -->
<div class="kartica dobavljaci-tabela animiraj" style="padding:0;overflow:hidden;">
<div style="overflow-x:auto;">
@@ -46,7 +48,7 @@
{{if .KontaktOsoba}}{{.KontaktOsoba}}{{else}}—{{end}}
</td>
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
{{if .Telefon}}{{.Telefon}}{{else}}—{{end}}
{{if .Telefon}}{{telefon .Telefon}}{{else}}—{{end}}
</td>
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
{{if .Email}}{{.Email}}{{else}}—{{end}}
@@ -70,7 +72,7 @@
{{else}}
<tr>
<td colspan="5" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
Nema dobavljača. <a href="/dobavljaci/novi" style="color:var(--sb-akcent);">Dodaj prvog dobavljača.</a>
{{if $.Pretraga}}Za datu pretragu nema dobavljača u bazi.{{else}}Nema dobavljača. <a href="/dobavljaci/novi" style="color:var(--sb-akcent);">Dodaj prvog dobavljača.</a>{{end}}
</td>
</tr>
{{end}}
@@ -107,7 +109,7 @@
{{end}}
{{if .Telefon}}
<div class="pomocni-tekst">
<span style="color:var(--tekst-glavni);font-weight:500;">Telefon:</span> {{.Telefon}}
<span style="color:var(--tekst-glavni);font-weight:500;">Telefon:</span> {{telefon .Telefon}}
</div>
{{end}}
{{if .Email}}
@@ -122,10 +124,33 @@
</div>
{{else}}
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
Nema dobavljača. <a href="/dobavljaci/novi" style="color:var(--sb-akcent);">Dodaj prvog dobavljača.</a>
{{if $.Pretraga}}Za datu pretragu nema dobavljača u bazi.{{else}}Nema dobavljača. <a href="/dobavljaci/novi" style="color:var(--sb-akcent);">Dodaj prvog dobavljača.</a>{{end}}
</div>
{{end}}
</div>
</div><!-- kraj #dobavljaci-rezultati -->
</div>
<script>
// Gasi animaciju redova pri pretrazi.
document.body.addEventListener('htmx:beforeRequest', function (e) {
var elt = e.detail && e.detail.elt;
if (elt && elt.name === 'pretraga') {
var rez = document.getElementById('dobavljaci-rezultati');
if (rez) rez.classList.add('bez-anim');
}
});
// Reflow pri sidebar navigaciji da nema bljeska animacije.
document.body.addEventListener('htmx:afterSwap', function (e) {
var t = e.detail && e.detail.target;
if (t && t.id === 'dobavljaci-rezultati') return;
var rez = document.getElementById('dobavljaci-rezultati');
if (!rez) return;
rez.classList.add('bez-anim');
void rez.offsetHeight;
rez.classList.remove('bez-anim');
});
</script>
{{end}}
+5 -5
View File
@@ -61,13 +61,13 @@
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">{{.MesecPrikaz}}</td>
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
{{if gt .Prodaja 0.0}}{{printf "%.0f" .Prodaja}} din{{else}}—{{end}}
{{if gt .Prodaja 0.0}}{{dinariCeli .Prodaja}} din{{else}}—{{end}}
</td>
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
{{if gt .Servis 0.0}}{{printf "%.0f" .Servis}} din{{else}}—{{end}}
{{if gt .Servis 0.0}}{{dinariCeli .Servis}} din{{else}}—{{end}}
</td>
<td style="padding:8px 12px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">
{{if gt .Ukupno 0.0}}{{printf "%.0f" .Ukupno}} din{{else}}—{{end}}
{{if gt .Ukupno 0.0}}{{dinariCeli .Ukupno}} din{{else}}—{{end}}
</td>
</tr>
{{end}}
@@ -145,7 +145,7 @@
<div style="font-size:11px;color:var(--tekst-sporedni);margin-top:1px;">{{.Kategorija}}</div>
</td>
<td style="padding:7px 8px;text-align:right;font-size:13px;color:var(--tekst-glavni);">{{.UkupnoKolicina}}</td>
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.0f" .UkupnoPrihod}} din</td>
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{dinariCeli .UkupnoPrihod}} din</td>
</tr>
{{end}}
</tbody>
@@ -176,7 +176,7 @@
</td>
<td style="padding:7px 8px;font-size:13px;color:var(--tekst-glavni);">{{.Naziv}}</td>
<td style="padding:7px 8px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">{{.BrojNaloga}}</td>
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.0f" .UkupnoVrednost}} din</td>
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{dinariCeli .UkupnoVrednost}} din</td>
</tr>
{{end}}
</tbody>
+17 -1
View File
@@ -47,6 +47,12 @@
<input type="text" name="naziv" placeholder="npr. Memorija, Diskovi, Kablovi..."
style="width:100%;">
</div>
<div>
<label class="polje-labela">Kôd (prefiks šifre)</label>
<input type="text" name="kod" placeholder="npr. MEM (daje šifre MEM-0001)"
maxlength="10" style="width:100%;text-transform:uppercase;">
<div class="pomocni-tekst" style="margin-top:4px;">Samo slova i brojevi. Prazno = šifre kreću sa ART-.</div>
</div>
<div>
<label class="polje-labela">Opis</label>
<input type="text" name="opis" placeholder="Kratak opis kategorije..."
@@ -76,7 +82,10 @@
{{range .Kategorije}}
<div class="kat-red animiraj" style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:0.5px solid var(--ivica);">
<div style="flex:1;">
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</div>
<div style="font-size:14px;color:var(--tekst-glavni);">
{{.Naziv}}
{{if .Kod}}<span style="font-size:11px;font-family:monospace;color:var(--tekst-sporedni);background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:4px;padding:1px 6px;margin-left:6px;">{{.Kod}}</span>{{end}}
</div>
{{if .Opis}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.Opis}}</div>
{{end}}
@@ -98,6 +107,13 @@
<input type="text" name="naziv" value="{{.Naziv}}" required
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div>
<label class="polje-labela">Kôd (prefiks šifre)</label>
<input type="text" name="kod" value="{{.Kod}}" maxlength="10"
placeholder="npr. MEM"
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;text-transform:uppercase;">
{{if not .Kod}}<div class="pomocni-tekst" style="margin-top:4px;color:#f59e0b;">Kôd nije postavljen — artikli će koristiti prefiks ART-.</div>{{end}}
</div>
<div>
<label class="polje-labela">Opis</label>
<input type="text" name="opis" value="{{.Opis}}"
+59 -10
View File
@@ -12,19 +12,40 @@
<div class="poruka-uspeh poruka-animacija">Klijent je uspešno obrisan.</div>
{{end}}
<!-- gornja traka: dugme + pretraga -->
<!-- gornja traka: dugme + interaktivna pretraga -->
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<a href="/klijenti/novi" class="btn-primarno">+ Novi klijent</a>
<form method="GET" action="/klijenti" style="display:flex;gap:8px;flex:1;min-width:200px;">
<form method="GET" action="/klijenti" style="display:flex;gap:8px;flex:1;min-width:200px;"
hx-get="/klijenti" hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true">
<input type="text" name="pretraga" value="{{.Pretraga}}"
placeholder="Pretraži po imenu ili nazivu firme..."
style="flex:1;">
<button type="submit" class="btn-primarno">
Traži
</button>
style="flex:1;"
hx-trigger="keyup changed delay:300ms, search"
hx-get="/klijenti" hx-include="[name='tip']:checked" hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true">
</form>
</div>
<div id="klijenti-rezultati">
<!-- filter po tipu klijenta -->
<div style="display:flex;gap:16px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
<input type="radio" name="tip" value="" {{if eq .TipFilter ""}}checked{{end}}
hx-trigger="change" hx-get="/klijenti" hx-include="[name='pretraga']"
hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true"> Svi
</label>
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
<input type="radio" name="tip" value="pravno" {{if eq .TipFilter "pravno"}}checked{{end}}
hx-trigger="change" hx-get="/klijenti" hx-include="[name='pretraga']"
hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true"> Firme
</label>
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
<input type="radio" name="tip" value="fizicko" {{if eq .TipFilter "fizicko"}}checked{{end}}
hx-trigger="change" hx-get="/klijenti" hx-include="[name='pretraga']"
hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true"> Fizička lica
</label>
</div>
<!-- desktop tabela -->
<div class="kartica klijenti-tabela kartica-tabela animiraj">
<div class="tabela-skrol">
@@ -52,7 +73,7 @@
{{end}}
</td>
<td class="pomocni-tekst">
{{if .Telefon}}{{.Telefon}}{{else}}—{{end}}
{{if .Telefon}}{{telefon .Telefon}}{{else}}—{{end}}
</td>
<td class="pomocni-tekst">
{{if .Email}}{{.Email}}{{else}}—{{end}}
@@ -79,7 +100,7 @@
{{else}}
<tr>
<td colspan="5" class="prazno-stanje">
Nema klijenata. <a href="/klijenti/novi" style="color:var(--sb-akcent);">Dodaj prvog klijenta.</a>
{{if $.Pretraga}}Za datu pretragu nema klijenata u bazi.{{else}}Nema klijenata. <a href="/klijenti/novi" style="color:var(--sb-akcent);">Dodaj prvog klijenta.</a>{{end}}
</td>
</tr>
{{end}}
@@ -120,7 +141,7 @@
<div class="kolona" style="gap:6px;">
{{if .Telefon}}
<div class="pomocni-tekst">
<span style="color:var(--tekst-glavni);font-weight:500;">Telefon:</span> {{.Telefon}}
<span style="color:var(--tekst-glavni);font-weight:500;">Telefon:</span> {{telefon .Telefon}}
</div>
{{end}}
{{if .Email}}
@@ -135,10 +156,38 @@
</div>
{{else}}
<div class="prazno-stanje">
Nema klijenata. <a href="/klijenti/novi" style="color:var(--sb-akcent);">Dodaj prvog klijenta.</a>
{{if $.Pretraga}}Za datu pretragu nema klijenata u bazi.{{else}}Nema klijenata. <a href="/klijenti/novi" style="color:var(--sb-akcent);">Dodaj prvog klijenta.</a>{{end}}
</div>
{{end}}
</div>
<!-- paginacija -->
{{if gt .UkupnoStranica 1}}
<div class="paginacija" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:12px 0;flex-wrap:wrap;">
<span style="font-size:13px;color:var(--tekst-sporedni);">{{.UkupnoKlijenata}} klijenata — {{.StranicaBr}}/{{.UkupnoStranica}}</span>
<div style="display:flex;gap:4px;">
{{if gt .StranicaBr 1}}
<a href="?stranica={{.StranicaPrev}}{{.StranicaQueryUrl}}" class="btn-sekundarno-malo" hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true">← Prethodna</a>
{{end}}
{{if lt .StranicaBr .UkupnoStranica}}
<a href="?stranica={{.StranicaNext}}{{.StranicaQueryUrl}}" class="btn-primarno-malo" hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true">Sledeća →</a>
{{end}}
</div>
</div>
{{end}}
</div><!-- kraj #klijenti-rezultati -->
</div>
<script>
// Gasi animaciju redova pri pretrazi i promeni tipa (ali NE pri paginaciji).
document.body.addEventListener('htmx:beforeRequest', function (e) {
var elt = e.detail && e.detail.elt;
if (elt && (elt.name === 'pretraga' || elt.name === 'tip')) {
var rez = document.getElementById('klijenti-rezultati');
if (rez) rez.classList.add('bez-anim');
}
});
</script>
{{end}}
+112 -21
View File
@@ -11,6 +11,15 @@
{{if .Obrisan}}
<div class="poruka-uspeh">Artikal je uspešno obrisan.</div>
{{end}}
{{if .Arhiviran}}
<div class="poruka-uspeh">Artikal je u prometu pa je arhiviran umesto obrisan. Možete ga pronaći među arhiviranim artiklima.</div>
{{end}}
{{if .Vracen}}
<div class="poruka-uspeh">Artikal je vraćen u aktivnu listu.</div>
{{end}}
{{if .Greska}}
<div class="poruka-greska">Operacija nije uspela. Pokušajte ponovo.</div>
{{end}}
{{if .Premesten}}
<div class="poruka-uspeh">Artikal je premešten u drugu kategoriju.</div>
{{end}}
@@ -25,34 +34,48 @@
{{end}}
</div>
<!-- pretraga i filteri -->
<form method="GET" action="/magacin" class="kolona" style="gap:10px;">
<!-- pretraga i filteri — interaktivna pretraga (hx-trigger) -->
<form method="GET" action="/magacin" class="kolona" style="gap:10px;" id="magacin-filteri"
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
<input type="text" name="pretraga" value="{{.Filter.Pretraga}}"
placeholder="Pretraži artikle..."
style="width:100%;">
placeholder="Pretraži po nazivu, šifri, barkodu, lokaciji..."
style="width:100%;"
hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search"
hx-get="/magacin" hx-include="closest form" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<select name="kategorija" style="flex:1;min-width:140px;">
<select name="kategorija" style="flex:1;min-width:140px;"
hx-trigger="change"
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
<option value="">Sve kategorije</option>
{{range .Kategorije}}
<option value="{{.ID}}" {{if eq (printf "%d" .ID) $.KategorijaIDStr}}selected{{end}}>{{.Naziv}}</option>
{{end}}
</select>
<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;background:var(--kartica);white-space:nowrap;">
<input type="checkbox" name="kriticni" value="1" {{if .Filter.SamoKriticni}}checked{{end}}>
<input type="checkbox" name="kriticni" value="1" {{if .Filter.SamoKriticni}}checked{{end}}
hx-trigger="change"
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
Samo kritični
</label>
<button type="submit" class="btn-primarno">
Traži
</button>
<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;background:var(--kartica);white-space:nowrap;">
<input type="checkbox" name="arhivirani" value="1" {{if .PrikazArhivirani}}checked{{end}}
hx-trigger="change"
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
Arhivirani
</label>
</div>
</form>
<!-- rezultati pretrage — HTMX zamenjuje samo ovo, polje za pretragu ostaje u fokusu -->
<div id="magacin-rezultati">
<!-- tabela artikala -->
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
<div style="overflow-x:auto;">
<table class="magacin-tabela tabela">
<thead>
<tr style="border-bottom:0.5px solid var(--ivica);">
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Šifra</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Naziv</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Kategorija</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-jak);">Količina</th>
@@ -64,24 +87,26 @@
<tbody>
{{range .Artikli}}
<tr class="animiraj red-tabele">
<td style="padding:12px 16px;font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</td>
<td style="padding:12px 16px;font-size:13px;font-family:monospace;white-space:nowrap;">{{if .Sifra}}<a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="color:var(--tekst-sporedni);">{{.Sifra}}</a>{{else}}—{{end}}</td>
<td style="padding:12px 16px;font-size:14px;"><a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="color:var(--tekst-glavni);font-weight:500;">{{.Naziv}}</a></td>
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}
</td>
<td style="padding:12px 16px;text-align:center;">
{{if .PratiLager}}
<span style="font-size:14px;font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">
{{.Kolicina}}
</span>
{{else}}
<span style="font-size:12px;color:var(--tekst-sporedni);">{{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}}</span>
{{end}}
</td>
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{printf "%.0f" .ProdajnaCena}} din</td>
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{dinariCeli .ProdajnaCena}} din</td>
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
{{if .Lokacija}}{{.Lokacija}}{{else}}—{{end}}
</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
<a href="/magacin/kartica/{{.ID}}" class="btn-sekundarno-malo">
Kartica
</a>
{{if index $.Dozvole "artikal.izmeni"}}
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">
Izmeni
@@ -92,18 +117,25 @@
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "tab")}}
{{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}}
{{if $.PrikazArhivirani}}
<a href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo"
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
Vrati
</a>
{{else}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši
</a>
{{end}}
{{end}}
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="7" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
Nema artikala. <a href="/magacin/novi" style="color:var(--sb-akcent);">Dodaj prvi artikal.</a>
{{if or $.Filter.Pretraga $.KategorijaIDStr $.Filter.SamoKriticni}}Za datu pretragu nema artikala u bazi.{{else}}Nema artikala. <a href="/magacin/novi" style="color:var(--sb-akcent);">Dodaj prvi artikal.</a>{{end}}
</td>
</tr>
{{end}}
@@ -118,13 +150,15 @@
<div class="kartica magacin-kartica animiraj">
<div class="red-izmedju" style="align-items:flex-start;gap:12px;margin-bottom:10px;flex-wrap:wrap;">
<div>
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</div>
<a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="display:inline-block;font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</a>
{{if .Sifra}}
<div style="font-size:12px;font-family:monospace;color:var(--tekst-sporedni);margin-top:2px;">{{.Sifra}}</div>
{{end}}
{{if .KategorijaNaziv}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.KategorijaNaziv}}</div>
{{end}}
</div>
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;">
<a href="/magacin/kartica/{{.ID}}" class="btn-sekundarno-malo">Kartica</a>
{{if index $.Dozvole "artikal.izmeni"}}
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">Izmeni</a>
{{template "promeniCenuMeni" (dict "ID" .ID "Cena" .ProdajnaCena)}}
@@ -133,20 +167,32 @@
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}}
{{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}}
{{if $.PrikazArhivirani}}
<a href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo"
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
Vrati
</a>
{{else}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši
</a>
{{end}}
{{end}}
</div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:10px;">
<div class="pomocni-tekst">
{{if .PratiLager}}
<span style="color:var(--tekst-glavni);font-weight:500;">Količina:</span>
<span style="font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">{{.Kolicina}}</span>
<span style="font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">{{.Kolicina}} {{.JedinicaMere}}</span>
{{else}}
<span style="color:var(--tekst-glavni);font-weight:500;">Tip:</span>
<span style="color:var(--tekst-sporedni);">{{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}}</span>
{{end}}
</div>
<div class="pomocni-tekst">
<span style="color:var(--tekst-glavni);font-weight:500;">Cena:</span> {{printf "%.0f" .ProdajnaCena}} din
<span style="color:var(--tekst-glavni);font-weight:500;">Cena:</span> {{dinariCeli .ProdajnaCena}} din
</div>
{{if .Lokacija}}
<div class="pomocni-tekst">
@@ -157,12 +203,57 @@
</div>
{{else}}
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
Nema artikala. <a href="/magacin/novi" style="color:var(--sb-akcent);">Dodaj prvi artikal.</a>
{{if or $.Filter.Pretraga $.KategorijaIDStr $.Filter.SamoKriticni}}Za datu pretragu nema artikala u bazi.{{else}}Nema artikala. <a href="/magacin/novi" style="color:var(--sb-akcent);">Dodaj prvi artikal.</a>{{end}}
</div>
{{end}}
</div>
<!-- paginacija -->
{{if gt .UkupnoStranica 1}}
<div class="paginacija" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:12px 0;flex-wrap:wrap;">
<span style="font-size:13px;color:var(--tekst-sporedni);">{{.UkupnoArtikala}} artikala — {{.StranicaBr}}/{{.UkupnoStranica}}</span>
<div style="display:flex;gap:4px;">
{{if gt .StranicaBr 1}}
<a href="?stranica={{.StranicaPrev}}{{.StranicaQueryUrl}}" class="btn-sekundarno-malo" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">← Prethodna</a>
{{end}}
{{if lt .StranicaBr .UkupnoStranica}}
<a href="?stranica={{.StranicaNext}}{{.StranicaQueryUrl}}" class="btn-primarno-malo" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">Sledeća →</a>
{{end}}
</div>
</div>
{{end}}
</div><!-- kraj #magacin-rezultati -->
</div>
<script>
// Gasi stagger animaciju redova pri pretrazi/filtriranju (ali NE pri paginaciji).
// hx-on atribut ne radi pouzdano u ovoj htmx verziji, pa koristimo globalni listener:
// kada zahtev kreće sa elementa unutar #magacin-filteri, dodamo .bez-anim na kontejner
// rezultata. Pošto se menja samo innerHTML kontejnera, klasa ostaje i kroz naredne zamene.
document.body.addEventListener('htmx:beforeRequest', function (e) {
var elt = e.detail && e.detail.elt;
if (elt && elt.closest && elt.closest('#magacin-filteri')) {
var rez = document.getElementById('magacin-rezultati');
if (rez) rez.classList.add('bez-anim');
}
});
// Pri sidebar navigaciji (boost menja ceo #glavni-sadrzaj) browser nakratko
// prikaže redove pre nego što animacija krene. Forsiramo reflow: prvo ugasimo
// animaciju, pa je odmah pustimo iz from-stanja → nema bljeska. Pretragu/
// paginaciju (target = #magacin-rezultati) NE diramo.
document.body.addEventListener('htmx:afterSwap', function (e) {
var t = e.detail && e.detail.target;
if (t && t.id === 'magacin-rezultati') return;
var rez = document.getElementById('magacin-rezultati');
if (!rez) return;
rez.classList.add('bez-anim');
void rez.offsetHeight;
rez.classList.remove('bez-anim');
});
</script>
{{end}}
{{/* padajući meni za premeštanje artikla — prima dict {ID, Kategorije}; koristi se i u tabeli i u mobilnoj kartici */}}
@@ -196,7 +287,7 @@
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<form method="POST" action="/magacin/promeni-cenu/{{.ID}}" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
<div style="font-size:13px;color:var(--tekst-sporedni);">Trenutna cena: <strong>{{printf "%.0f" .Cena}} din</strong></div>
<div style="font-size:13px;color:var(--tekst-sporedni);">Trenutna cena: <strong>{{dinariCeli .Cena}} din</strong></div>
<div>
<label class="polje-labela">Nova cena (din)</label>
<input type="number" name="nova_cena" min="0" step="0.01" value="{{printf "%.2f" .Cena}}" required
+99 -5
View File
@@ -29,8 +29,8 @@
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">Šifra artikla</label>
<input type="text" name="sifra" value="{{.Artikal.Sifra}}"
placeholder="npr. ART-00001"
<input type="text" name="sifra" id="sifra-input" value="{{.Artikal.Sifra}}"
placeholder="npr. KOMP-0001"
style="width:100%;font-family:monospace;">
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Ako ostaviš prazno, šifra se automatski dodeljuje.</div>
</div>
@@ -53,6 +53,32 @@
style="width:100%;">
</div>
<!-- tip i jedinica mere -->
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">Tip artikla</label>
<select name="tip" id="tip-artikla" style="width:100%;">
<option value="proizvod" {{if eq .Artikal.Tip "proizvod"}}selected{{end}}>Proizvod (prati lager)</option>
<option value="usluga" {{if eq .Artikal.Tip "usluga"}}selected{{end}}>Usluga</option>
<option value="trosak" {{if eq .Artikal.Tip "trosak"}}selected{{end}}>Trošak</option>
</select>
</div>
<div>
<label class="polje-labela">Jedinica mere</label>
{{$jm := .Artikal.JedinicaMere}}
<select name="jedinica_mere" style="width:100%;">
<option value="kom" {{if eq $jm "kom"}}selected{{end}}>kom</option>
<option value="sat" {{if eq $jm "sat"}}selected{{end}}>sat</option>
<option value="set" {{if eq $jm "set"}}selected{{end}}>set</option>
<option value="m" {{if eq $jm "m"}}selected{{end}}>m</option>
<option value="m2" {{if eq $jm "m2"}}selected{{end}}></option>
<option value="l" {{if eq $jm "l"}}selected{{end}}>l</option>
<option value="kg" {{if eq $jm "kg"}}selected{{end}}>kg</option>
<option value="pak" {{if eq $jm "pak"}}selected{{end}}>pak</option>
</select>
</div>
</div>
<!-- kategorija -->
<div>
<label class="polje-labela">Kategorija</label>
@@ -72,8 +98,8 @@
style="width:100%;resize:vertical;">{{.Artikal.Opis}}</textarea>
</div>
<!-- količina i minimum -->
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<!-- količina i minimum (samo za proizvode) -->
<div class="forma-grid-2" data-lager style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">Količina na stanju</label>
<input type="number" name="kolicina" value="{{.Artikal.Kolicina}}" min="0" style="width:100%;">
@@ -102,8 +128,24 @@
placeholder="prazno = po kategoriji / globalna">
</div>
<!-- lokacija -->
<!-- dobavljači koji isporučuju artikal -->
{{if .Dobavljaci}}
<div>
<label class="polje-labela">Dobavljači</label>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:6px;padding:10px 12px;border:0.5px solid var(--ivica);border-radius:8px;">
{{range .Dobavljaci}}
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;padding:4px 2px;">
<input type="checkbox" name="dobavljaci" value="{{.ID}}" {{if index $.IzabraniDobavljaci .ID}}checked{{end}} style="flex-shrink:0;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{.Naziv}}</span>
</label>
{{end}}
</div>
<div class="pomocni-tekst" style="margin-top:4px;">Određuje koje artikle dobavljač nudi pri nabavci.</div>
</div>
{{end}}
<!-- lokacija (samo za proizvode) -->
<div data-lager>
<label class="polje-labela">Lokacija u magacinu</label>
<input type="text" name="lokacija" value="{{.Artikal.Lokacija}}"
placeholder="npr. Polica A3, Kutija 2..."
@@ -130,4 +172,56 @@
</form>
</div>
</div>
<script>
(function () {
// sakrivanje polja lagera za usluge i troškove
var tip = document.getElementById('tip-artikla');
var lager = document.querySelectorAll('[data-lager]');
function azurirajLager() {
var prati = tip.value === 'proizvod';
lager.forEach(function (el) { el.style.display = prati ? '' : 'none'; });
}
tip.addEventListener('change', azurirajLager);
azurirajLager();
// auto-predlog šifre pri promeni kategorije (samo za nov artikal i ako šifra nije ručno menjana)
var kat = document.querySelector('select[name="kategorija_id"]');
var sifra = document.getElementById('sifra-input');
var jeIzmena = {{if .Izmena}}true{{else}}false{{end}};
if (sifra) {
sifra.addEventListener('input', function () { sifra.dataset.rucno = '1'; });
}
if (kat && sifra && !jeIzmena) {
kat.addEventListener('change', function () {
if (sifra.dataset.rucno === '1' && sifra.value !== '') return;
fetch('/magacin/sledeca-sifra?kategorija=' + encodeURIComponent(kat.value))
.then(function (r) { return r.ok ? r.text() : ''; })
.then(function (t) { if (t) sifra.value = t; });
});
}
// dvosmerno povezivanje: nabavna + marža → prodajna, i prodajna → marža
var nabavna = document.querySelector('[name="nabavna_cena"]');
var prodajna = document.querySelector('[name="prodajna_cena"]');
var marza = document.querySelector('[name="marza"]');
function broj(el) { return parseFloat(el.value) || 0; }
// postavljanje .value programski ne okida 'input', pa nema beskonačne petlje
function izProdajne() {
var n = broj(nabavna);
if (n <= 0) return;
prodajna.value = (n * (1 + broj(marza) / 100)).toFixed(2);
}
function izMarze() {
var n = broj(nabavna), p = broj(prodajna);
if (n <= 0) { marza.value = ''; return; }
marza.value = ((p / n - 1) * 100).toFixed(2);
}
if (nabavna && prodajna && marza) {
marza.addEventListener('input', izProdajne);
nabavna.addEventListener('input', izProdajne);
prodajna.addEventListener('input', izMarze);
}
})();
</script>
{{end}}
@@ -39,6 +39,55 @@
</div>
</div>
<!-- dobavljači artikla -->
<div class="kartica animiraj">
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-bottom:14px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);">
Dobavljači
<span style="font-size:12px;font-weight:400;color:var(--tekst-slabi);margin-left:8px;">{{len .Dobavljaci}}</span>
</div>
{{if .Dobavljaci}}
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px;">
{{range .Dobavljaci}}
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;">
<div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</div>
{{if or .Telefon .KontaktOsoba}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">
{{if .KontaktOsoba}}{{.KontaktOsoba}}{{end}}{{if and .KontaktOsoba .Telefon}} · {{end}}{{if .Telefon}}{{telefon .Telefon}}{{end}}
</div>
{{end}}
</div>
{{if index $.Dozvole "artikal.izmeni"}}
<form method="POST" action="/magacin/kartica/{{$.Artikal.ID}}/dobavljac/obrisi" style="margin:0;">
<input type="hidden" name="dobavljac_id" value="{{.ID}}">
<button type="submit" class="btn-obrisi-malo" data-potvrda="Ukloniti dobavljača {{.Naziv}} sa ovog artikla?">Ukloni</button>
</form>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div style="font-size:13px;color:var(--tekst-sporedni);margin-bottom:16px;">Nijedan dobavljač nije vezan za ovaj artikal.</div>
{{end}}
{{if index .Dozvole "artikal.izmeni"}}
{{if .DostupniDobavljaci}}
<form method="POST" action="/magacin/kartica/{{.Artikal.ID}}/dobavljac/dodaj" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select name="dobavljac_id" required style="flex:1;min-width:200px;">
<option value="">— izaberi dobavljača —</option>
{{range .DostupniDobavljaci}}
<option value="{{.ID}}">{{.Naziv}}</option>
{{end}}
</select>
<button type="submit" class="btn-primarno">+ Dodaj dobavljača</button>
</form>
{{else}}
<div style="font-size:12px;color:var(--tekst-slabi);">Svi dobavljači su već vezani za ovaj artikal.</div>
{{end}}
{{end}}
</div>
<!-- tabela promena -->
<div class="kartica animiraj">
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-bottom:14px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);">
+9 -9
View File
@@ -43,7 +43,7 @@
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ukupan iznos</div>
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">
{{printf "%.2f" .Nabavka.Ukupno}} din
{{dinari .Nabavka.Ukupno}} din
</div>
</div>
</div>
@@ -71,8 +71,8 @@
<tr class="animiraj red-tabele">
<td style="padding:10px 16px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
<td style="padding:10px 16px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
<td style="padding:10px 16px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .CenaPoKomadu}} din</td>
<td style="padding:10px 16px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
<td style="padding:10px 16px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{dinari .CenaPoKomadu}} din</td>
<td style="padding:10px 16px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
</tr>
{{else}}
<tr>
@@ -86,7 +86,7 @@
<tfoot>
<tr style="border-top:0.5px solid var(--ivica);">
<td colspan="3" style="padding:10px 16px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-sporedni);">Ukupno:</td>
<td style="padding:10px 16px;text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);">{{printf "%.2f" .Nabavka.Ukupno}} din</td>
<td style="padding:10px 16px;text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);">{{dinari .Nabavka.Ukupno}} din</td>
</tr>
</tfoot>
{{end}}
@@ -105,18 +105,18 @@
</div>
<div>
<div style="font-size:11px;color:var(--tekst-sporedni);">Cena/kom</div>
<div style="font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .CenaPoKomadu}} din</div>
<div style="font-size:14px;color:var(--tekst-sporedni);">{{dinari .CenaPoKomadu}} din</div>
</div>
<div style="text-align:right;">
<div style="font-size:11px;color:var(--tekst-sporedni);">Ukupno</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</div>
</div>
</div>
</div>
{{end}}
{{if .Stavke}}
<div style="text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);padding:8px 4px;">
Ukupno: {{printf "%.2f" .Nabavka.Ukupno}} din
Ukupno: {{dinari .Nabavka.Ukupno}} din
</div>
{{end}}
</div>
@@ -135,12 +135,12 @@
{{range .Troskovi}}
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</span>
<span style="font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .Iznos}} din</span>
<span style="font-size:14px;color:var(--tekst-sporedni);">{{dinari .Iznos}} din</span>
</div>
{{end}}
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;">
<span style="font-size:13px;font-weight:500;color:var(--tekst-sporedni);">Ukupno troškovi:</span>
<span style="font-size:15px;font-weight:600;color:var(--tekst-glavni);">{{printf "%.2f" .UkupanTrosak}} din</span>
<span style="font-size:15px;font-weight:600;color:var(--tekst-glavni);">{{dinari .UkupanTrosak}} din</span>
</div>
</div>
</div>
+23 -13
View File
@@ -47,12 +47,19 @@
+ Novi dobavljač
</button>
</div>
<select name="dobavljac_id" x-ref="selDobavljac" style="width:100%;">
<select name="dobavljac_id" x-ref="selDobavljac" x-model="dobavljacId" style="width:100%;">
<option value="">— bez dobavljača —</option>
{{range .Dobavljaci}}
<option value="{{.ID}}">{{.Naziv}}</option>
{{end}}
</select>
<label x-show="dobavljacId" style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;margin-top:8px;color:var(--tekst-sporedni);">
<input type="checkbox" x-model="prikaziSveArtikle">
Prikaži sve artikle (ne samo dobavljačeve)
</label>
<div x-show="dobavljacId && prikaziSveArtikle" class="pomocni-tekst" style="margin-top:4px;">
Napomena: izabrani artikal koji nije od ovog dobavljača biće mu dodat pri čuvanju nabavke.
</div>
</div>
<div>
<label class="polje-labela">Napomena</label>
@@ -73,9 +80,6 @@
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
+ Novi artikal
</button>
<button type="button" @click="dodajStavku()" class="btn-primarno" style="font-size:13px;padding:6px 14px;">
+ Dodaj stavku
</button>
</div>
</div>
@@ -100,7 +104,7 @@
<select :name="'artikal_id[]'" x-model="stavka.artikal_id"
@change="izaberiArtikal(stavka)" :disabled="isMobile" style="width:100%;">
<option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id">
<template x-for="a in artikliZaDobavljaca()" :key="a.id">
<option :value="a.id" x-text="a.naziv"></option>
</template>
</select>
@@ -126,11 +130,10 @@
min="0" step="0.01" :disabled="isMobile" style="width:100%;text-align:right;">
</td>
<td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
<span x-text="ukupnoStavke(stavka) + ' din'"></span>
<span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
</td>
<td style="padding:8px 10px;text-align:center;">
<button type="button" @click="ukloniStavku(i)"
x-show="stavke.length > 1"
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'"
@@ -143,13 +146,17 @@
<tr style="border-top:0.5px solid var(--ivica);">
<td colspan="5" style="padding:10px 10px;text-align:right;font-size:13px;color:var(--tekst-sporedni);font-weight:500;">Ukupno:</td>
<td style="padding:10px 10px;text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);">
<span x-text="ukupnoSvega() + ' din'"></span>
<span x-text="fmtDin(ukupnoSvega()) + ' din'"></span>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<button type="button" x-show="!isMobile" @click="dodajStavku()" class="btn-primarno"
style="width:100%;font-size:14px;padding:10px;margin-top:10px;">
+ Dodaj stavku
</button>
<!-- mobilne kartice stavki (display kontroliše .stavke-kartice: none na desktopu,
flex na mobilnom @media — inline display:none bi pobedio @media, zato ga NEMA) -->
@@ -160,7 +167,6 @@
<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"
style="background:none;border:0.5px solid #dc2626;color:#dc2626;cursor:pointer;font-size:13px;padding:2px 8px;border-radius:4px;">
Ukloni
</button>
@@ -171,7 +177,7 @@
<select :name="'artikal_id[]'" x-model="stavka.artikal_id"
@change="izaberiArtikal(stavka)" :disabled="!isMobile" style="width:100%;">
<option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id">
<template x-for="a in artikliZaDobavljaca()" :key="a.id">
<option :value="a.id" x-text="a.naziv"></option>
</template>
</select>
@@ -197,13 +203,17 @@
</div>
</div>
<div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
Ukupno: <span x-text="ukupnoStavke(stavka) + ' din'"></span>
Ukupno: <span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
</div>
</div>
</div>
</template>
<button type="button" x-show="isMobile" @click="dodajStavku()" class="btn-primarno"
style="width:100%;font-size:14px;padding:10px;margin-top:4px;">
+ Dodaj stavku
</button>
<div style="text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);padding:8px 4px;">
Ukupno: <span x-text="ukupnoSvega() + ' din'"></span>
Ukupno: <span x-text="fmtDin(ukupnoSvega()) + ' din'"></span>
</div>
</div>
</div>
@@ -241,7 +251,7 @@
</div>
</template>
<div style="text-align:right;font-size:13px;color:var(--tekst-sporedni);">
Ukupno troškovi: <strong x-text="ukupanTrosak().toFixed(2) + ' din'"></strong>
Ukupno troškovi: <strong x-text="fmtDin(ukupanTrosak()) + ' din'"></strong>
</div>
</div>
</template>
+2 -2
View File
@@ -43,7 +43,7 @@
{{if .Napomena}}{{.Napomena}}{{else}}—{{end}}
</td>
<td style="padding:12px 16px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{printf "%.2f" .Ukupno}} din
{{dinari .Ukupno}} din
</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
@@ -87,7 +87,7 @@
</div>
</div>
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);white-space:nowrap;">
{{printf "%.2f" .Ukupno}} din
{{dinari .Ukupno}} din
</div>
</div>
{{if .Napomena}}
+3 -3
View File
@@ -42,10 +42,10 @@
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;white-space:nowrap;">{{.Datum.Format "02.01.2006."}}</td>
<td style="padding:10px 12px;">{{.ArtikalNaziv}}</td>
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{printf "%.2f" .StaraCena}}</td>
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{printf "%.2f" .NovaCena}}</td>
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{dinari .StaraCena}}</td>
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{dinari .NovaCena}}</td>
<td style="padding:10px 12px;text-align:right;white-space:nowrap;color:{{if .Poskupljenje}}var(--greska){{else}}var(--uspeh){{end}};">
{{if .Poskupljenje}}+{{end}}{{printf "%.2f" .Razlika}}
{{if .Poskupljenje}}+{{end}}{{dinari .Razlika}}
<div style="font-size:11px;">{{if .Poskupljenje}}+{{end}}{{printf "%.1f" .Procenat}}%</div>
</td>
<td style="padding:10px 12px;">
+12 -12
View File
@@ -48,12 +48,12 @@
<td style="padding:10px 12px;white-space:nowrap;">{{.DatumPrometa.Format "02.01.2006."}}</td>
<td style="padding:10px 12px;">{{.BrojDokumenta}}</td>
<td style="padding:10px 12px;">{{.KupacNaziv}}{{if .KupacPib}}<div style="font-size:11px;color:var(--tekst-sporedni);">{{.OznakaPoreskogBroja}}: {{.KupacPib}}</div>{{end}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OslobodenUkupno}}</td>
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{printf "%.2f" .Ukupno}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .OslobodenUkupno}}</td>
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{dinari .Ukupno}}</td>
<td style="padding:10px 12px;text-align:right;white-space:nowrap;">
{{if eq .Izvor "rucno"}}
<form method="POST" action="/pdv/kir/obrisi/{{.ID}}" style="display:inline;">
@@ -72,12 +72,12 @@
<tfoot>
<tr style="border-top:0.5px solid var(--ivica);font-weight:500;background:var(--pozadina);">
<td style="padding:10px 12px;" colspan="3">UKUPNO ({{len .Zapisi}})</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OslobodenUkupno}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.Ukupno}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OslobodenUkupno}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.Ukupno}}</td>
<td></td>
</tr>
</tfoot>
+14 -14
View File
@@ -49,13 +49,13 @@
<td style="padding:10px 12px;white-space:nowrap;">{{.DatumPrometa.Format "02.01.2006."}}</td>
<td style="padding:10px 12px;">{{.BrojDokumenta}}{{if .Uvoz}}<div style="display:inline-block;margin-top:2px;font-size:10px;font-weight:600;color:var(--sb-akcent);border:0.5px solid var(--sb-akcent);border-radius:4px;padding:0 5px;">UVOZ</div>{{end}}</td>
<td style="padding:10px 12px;">{{.DobavljacNaziv}}{{if .DobavljacPib}}<div style="font-size:11px;color:var(--tekst-sporedni);">{{.OznakaPoreskogBroja}}: {{.DobavljacPib}}</div>{{end}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvBezOdbitka}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OslobodenNabavka}}</td>
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{printf "%.2f" .Ukupno}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvBezOdbitka}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .OslobodenNabavka}}</td>
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{dinari .Ukupno}}</td>
<td style="padding:10px 12px;text-align:right;white-space:nowrap;">
{{if eq .Izvor "rucno"}}
<form method="POST" action="/pdv/kpr/obrisi/{{.ID}}" style="display:inline;">
@@ -74,13 +74,13 @@
<tfoot>
<tr style="border-top:0.5px solid var(--ivica);font-weight:500;background:var(--pozadina);">
<td style="padding:10px 12px;" colspan="3">UKUPNO ({{len .Zapisi}})</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvBezOdbitka}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OslobodenNabavka}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.Ukupno}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvBezOdbitka}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OslobodenNabavka}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.Ukupno}}</td>
<td></td>
</tr>
</tfoot>
+13 -13
View File
@@ -38,23 +38,23 @@
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;padding-left:24px;">Opšta stopa (20%)</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .KirSume.OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.IzlazniPdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .KirSume.OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.IzlazniPdvOpsta}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;padding-left:24px;">Posebna stopa (10%)</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .KirSume.OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.IzlazniPdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .KirSume.OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.IzlazniPdvPosebna}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;padding-left:24px;color:var(--tekst-sporedni);">Oslobođen promet</td>
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{printf "%.2f" .KirSume.OslobodenUkupno}}</td>
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{dinari .KirSume.OslobodenUkupno}}</td>
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);"></td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);font-weight:500;background:var(--pozadina);">
<td style="padding:10px 12px;">Ukupno izlazni PDV</td>
<td style="padding:10px 12px;"></td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.IzlazniPdvUkupno}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.IzlazniPdvUkupno}}</td>
</tr>
<!-- odbitni (prethodni) PDV — iz KPR -->
@@ -63,23 +63,23 @@
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;padding-left:24px;">Opšta stopa (20%)</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .KprSume.OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.OdbitniPdvOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .KprSume.OsnovicaOpsta}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.OdbitniPdvOpsta}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;padding-left:24px;">Posebna stopa (10%)</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .KprSume.OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.OdbitniPdvPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .KprSume.OsnovicaPosebna}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.OdbitniPdvPosebna}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;padding-left:24px;color:var(--tekst-sporedni);">PDV bez prava na odbitak</td>
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);"></td>
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{printf "%.2f" .KprSume.PdvBezOdbitka}}</td>
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{dinari .KprSume.PdvBezOdbitka}}</td>
</tr>
<tr style="font-weight:500;background:var(--pozadina);">
<td style="padding:10px 12px;">Ukupno odbitni PDV</td>
<td style="padding:10px 12px;"></td>
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.OdbitniPdvUkupno}}</td>
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.OdbitniPdvUkupno}}</td>
</tr>
</tbody>
</table>
@@ -96,7 +96,7 @@
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">izlazni odbitni PDV</div>
</div>
<div style="font-size:24px;font-weight:600;color:{{if .Obracun.ZaUplatu}}var(--greska){{else}}var(--uspeh){{end}};white-space:nowrap;">
{{printf "%.2f" .Obracun.ObavezaApsolutna}} RSD
{{dinari .Obracun.ObavezaApsolutna}} RSD
</div>
</div>
@@ -33,6 +33,18 @@
</div>
</div>
<div style="max-width:280px;">
<label for="predracun_rok_dana" class="polje-labela">
Rok važenja predračuna (dana)
</label>
<input type="number" id="predracun_rok_dana" name="predracun_rok_dana"
min="1" max="90" value="{{.PredracunRokDana}}"
style="width:100%;">
<div class="pomocni-tekst" style="margin-top:6px;">
Koliko dana važi predračun od dana izdavanja. Štampa se na dokumentu kao „Važi do".
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-top:20px;">
+2 -2
View File
@@ -37,7 +37,7 @@
<td style="padding: 12px 16px; font-size: 13px; font-family: monospace; color: var(--tekst-glavni)">{{.BrojNaloga}}</td>
<td style="padding: 12px 16px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.Datum.Format "02.01.2006."}}</td>
<td style="padding: 12px 16px; font-size: 14px; font-weight: 500; color: var(--tekst-glavni)">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}}</td>
<td style="padding: 12px 16px; text-align: right; font-size: 14px; font-weight: 500; color: var(--tekst-glavni)">{{printf "%.2f" .Ukupno}} din</td>
<td style="padding: 12px 16px; text-align: right; font-size: 14px; font-weight: 500; color: var(--tekst-glavni)">{{dinari .Ukupno}} din</td>
<td style="padding: 12px 16px; text-align: center">
<div style="display: flex; align-items: center; justify-content: center; gap: 8px">
<a href="/prodaja/{{.ID}}" class="btn-primarno-malo">Detalji</a>
@@ -74,7 +74,7 @@
<div style="font-size: 14px; font-weight: 500; color: var(--tekst-glavni); margin-top: 2px">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}Bez klijenta{{end}}</div>
<div style="font-size: 12px; color: var(--tekst-sporedni); margin-top: 2px">{{.Datum.Format "02.01.2006."}}</div>
</div>
<div style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni); white-space: nowrap">{{printf "%.2f" .Ukupno}} din</div>
<div style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni); white-space: nowrap">{{dinari .Ukupno}} din</div>
</div>
<a href="/prodaja/{{.ID}}" class="btn-primarno-malo" style="justify-content: center; width: 100%; box-sizing: border-box">Detalji</a>
</div>
+4 -4
View File
@@ -121,7 +121,7 @@
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ukupno</div>
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">
{{printf "%.2f" .Nalog.Ukupno}} din
{{dinari .Nalog.Ukupno}} din
</div>
</div>
{{if .Nalog.RazlogStorniranja}}
@@ -205,18 +205,18 @@
<tr style="border-bottom: 0.5px solid var(--ivica)">
<td style="padding:10px 20px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
<td style="padding:10px 20px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
<td style="padding:10px 20px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .CenaPoKomadu}} din</td>
<td style="padding:10px 20px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{dinari .CenaPoKomadu}} din</td>
<td style="padding:10px 20px;text-align:center;font-size:13px;color:var(--tekst-sporedni);">
{{if .PdvStopa}}{{printf "%.0f" .PdvStopa}}%{{else}}—{{end}}
</td>
<td style="padding:10px 20px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
<td style="padding:10px 20px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr style="border-top: 0.5px solid var(--ivica)">
<td colspan="4" style="padding:12px 20px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-sporedni);">Ukupno:</td>
<td style="padding:12px 20px;text-align:right;font-size:16px;font-weight:600;color:var(--sb-akcent);">{{printf "%.2f" .Nalog.Ukupno}} din</td>
<td style="padding:12px 20px;text-align:right;font-size:16px;font-weight:600;color:var(--sb-akcent);">{{dinari .Nalog.Ukupno}} din</td>
</tr>
</tfoot>
</table>
+4 -4
View File
@@ -217,7 +217,7 @@
font-weight: 500;
color: var(--tekst-glavni);
">
<span x-text="ukupnoStavke(stavka) + ' din'"></span>
<span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
</td>
<td style="padding: 8px 10px; text-align: center">
<button
@@ -265,7 +265,7 @@
font-weight: 600;
color: var(--tekst-glavni);
">
<span x-text="ukupnoSvega() + ' din'"></span>
<span x-text="fmtDin(ukupnoSvega()) + ' din'"></span>
</td>
<td></td>
</tr>
@@ -390,7 +390,7 @@
font-weight: 500;
color: var(--tekst-glavni);
">
Ukupno: <span x-text="ukupnoStavke(stavka) + ' din'"></span>
Ukupno: <span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
</div>
</div>
</div>
@@ -403,7 +403,7 @@
color: var(--tekst-glavni);
padding: 8px 4px;
">
Ukupno: <span x-text="ukupnoSvega() + ' din'"></span>
Ukupno: <span x-text="fmtDin(ukupnoSvega()) + ' din'"></span>
</div>
</div>
</div>
+5 -5
View File
@@ -46,7 +46,7 @@
{{end}} {{if .Adresa}}
<div class="firma-kontakt">{{.Adresa}}</div>
{{end}} {{if .Telefon}}
<div class="firma-kontakt">{{.Telefon}}</div>
<div class="firma-kontakt">{{telefon .Telefon}}</div>
{{end}} {{if .PIB}}
<div class="firma-kontakt">PIB: {{.PIB}}</div>
{{end}}
@@ -81,16 +81,16 @@
{{range .Stavke}}
<tr>
<td>{{.ArtikalNaziv}}</td>
<td>{{.Kolicina}}</td>
<td>{{printf "%.2f" .CenaPoKomadu}} din</td>
<td>{{printf "%.2f" .Ukupno}} din</td>
<td>{{.Kolicina}} {{.JedinicaMere}}</td>
<td>{{dinari .CenaPoKomadu}} din</td>
<td>{{dinari .Ukupno}} din</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="ukupno-label">Ukupno za naplatu:</td>
<td class="ukupno-iznos">{{printf "%.2f" .Nalog.Ukupno}} din</td>
<td class="ukupno-iznos">{{dinari .Nalog.Ukupno}} din</td>
</tr>
</tfoot>
</table>
+38 -10
View File
@@ -83,24 +83,23 @@
<form method="POST" action="/profil/animacija">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="lokalna_brzina_animacije" id="brzina-hidden" value="{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}{{else}}0.4{{end}}">
<div class="kolona" style="gap:16px;">
<div style="max-width:300px;">
<label class="polje-labela" for="anim-select">Vrsta animacije pri učitavanju</label>
<select id="anim-select" name="lokalna_animacija" style="width:100%;" onchange="animPreview(this.value)"><!-- preview se primenjuje na wrapper, ne na body -->
<select id="anim-select" name="lokalna_animacija" style="width:100%;" onchange="animPreview(this.value)">
<option value="" {{if eq .LokalnaAnimacija ""}}selected{{end}}>Podrazumevano (klizanje gore)</option>
<option value="bez" {{if eq .LokalnaAnimacija "bez"}}selected{{end}}>Bez animacije</option>
<option value="fadeInUp" {{if eq .LokalnaAnimacija "fadeInUp"}}selected{{end}}>Klizanje gore (fadeInUp)</option>
<option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje (fadeIn)</option>
<option value="scaleIn" {{if eq .LokalnaAnimacija "scaleIn"}}selected{{end}}>Zumiranje (scaleIn)</option>
<option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva (slideLeft)</option>
<option value="fadeInUp" {{if eq .LokalnaAnimacija "fadeInUp"}}selected{{end}}>Klizanje gore</option>
<option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje</option>
<option value="blurIn" {{if eq .LokalnaAnimacija "blurIn"}}selected{{end}}>Zamagljivanje</option>
<option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva</option>
</select>
</div>
<!-- preview -->
<div>
<div class="pomocni-tekst" style="margin-bottom:8px;">Pregled:</div>
<!-- preview wrapper nosi data-animacija atribut; isti CSS [data-animacija] .animiraj
koji radi globalno na body radi i ovde, ali izolovano — pogađa SAMO redove unutar wrappera -->
<div id="anim-preview-wrap" style="border:0.5px solid var(--ivica);border-radius:8px;overflow:hidden;max-width:340px;">
<table style="width:100%;border-collapse:collapse;">
<tbody id="anim-preview-body">
@@ -116,6 +115,20 @@
</button>
</div>
<!-- slider brzine unutar iste forme -->
<div style="max-width:340px;">
<label class="polje-labela" for="brzina-slider">Trajanje efekta: <span id="brzina-labela">{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}s{{else}}0.4s{{end}}</span></label>
<input type="range" id="brzina-slider"
min="0.1" max="0.8" step="0.1"
value="{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}{{else}}0.4{{end}}"
style="width:100%;margin-top:8px;accent-color:var(--sb-akcent);"
oninput="brzinaPromena(this.value)">
<div style="display:flex;justify-content:space-between;margin-top:4px;">
<span style="font-size:11px;color:var(--tekst-sporedni);">0.1s (brže)</span>
<span style="font-size:11px;color:var(--tekst-sporedni);">0.8s (sporije)</span>
</div>
</div>
<div>
<button type="submit" class="btn-primarno">Sačuvaj</button>
</div>
@@ -376,7 +389,7 @@ var animVrednosti = {
'bez': 'bez',
'fadeInUp': 'fadeInUp',
'fadeIn': 'fadeIn',
'scaleIn': 'scaleIn',
'blurIn': 'blurIn',
'slideLeft': 'slideLeft'
};
// postavlja data-animacija na PREVIEW wrapper (ne na body), pa CSS
@@ -385,10 +398,13 @@ function animPreview(val) {
var wrap = document.getElementById('anim-preview-wrap');
if (!wrap) return;
var anim = animVrednosti[val] || 'fadeInUp';
// restartuj animaciju: skini atribut, izazovi reflow, pa ga vrati
// skini data-animacija i klasu animiraj sa redova da bi se animacija resetovala
wrap.removeAttribute('data-animacija');
void wrap.offsetHeight; // reflow da bi se animacija ponovo okinula
var redovi = wrap.querySelectorAll('.animiraj');
redovi.forEach(function(r) { r.classList.remove('animiraj'); });
void wrap.offsetHeight; // reflow — bez ovoga browser spoji promene i ne restartuje
wrap.setAttribute('data-animacija', anim);
redovi.forEach(function(r) { r.classList.add('animiraj'); });
}
// "Ponovi pregled" — ponovo okida animaciju za trenutno izabrani stil
function animPreviewPonovi() {
@@ -399,5 +415,17 @@ function animPreviewPonovi() {
var sel = document.getElementById('anim-select');
if (sel) animPreview(sel.value);
})();
function brzinaPromena(val) {
var v = parseFloat(val).toFixed(1);
document.getElementById('brzina-hidden').value = v;
document.getElementById('brzina-labela').textContent = v + 's';
document.body.dataset.brzinaAnimacije = v;
var wrap = document.getElementById('anim-preview-wrap');
if (wrap) wrap.style.setProperty('--anim-trajanje', v + 's');
// ponovi preview da korisnik odmah vidi novu brzinu
var sel = document.getElementById('anim-select');
if (sel) animPreview(sel.value);
}
</script>
{{end}}
+2 -2
View File
@@ -94,7 +94,7 @@
{{else}}
<tr>
<td colspan="6" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
Nema servisnih naloga. <a href="/servis/novi" style="color:var(--sb-akcent);">Dodaj prvi nalog.</a>
{{if or $.Pretraga $.FilterStatus}}Za datu pretragu nema servisnih naloga u bazi.{{else}}Nema servisnih naloga. <a href="/servis/novi" style="color:var(--sb-akcent);">Dodaj prvi nalog.</a>{{end}}
</td>
</tr>
{{end}}
@@ -145,7 +145,7 @@
</div>
{{else}}
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
Nema servisnih naloga. <a href="/servis/novi" style="color:var(--sb-akcent);">Dodaj prvi nalog.</a>
{{if or $.Pretraga $.FilterStatus}}Za datu pretragu nema servisnih naloga u bazi.{{else}}Nema servisnih naloga. <a href="/servis/novi" style="color:var(--sb-akcent);">Dodaj prvi nalog.</a>{{end}}
</div>
{{end}}
</div>
+12 -5
View File
@@ -57,12 +57,19 @@
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
{{if not (or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto"))}}
<a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno">
Radni nalog
</a>
<a href="/servis/{{.Nalog.ID}}/predracun" target="_blank" class="btn-sekundarno">
Predračun
</a>
{{end}}
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
Otpremnica
</a>
{{end}}
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno">
Izmeni nalog
</a>
@@ -181,7 +188,7 @@
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ugrađeni delovi</div>
<div style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">
{{printf "%.2f" .UkupnoDelovi}} din
{{dinari .UkupnoDelovi}} din
</div>
</div>
{{end}}
@@ -189,7 +196,7 @@
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ukupno</div>
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">
{{printf "%.2f" .UkupnoSve}} din
{{dinari .UkupnoSve}} din
</div>
</div>
{{end}}
@@ -203,7 +210,7 @@
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Za naplatu</div>
<div style="font-size:20px;font-weight:600;color:#16a34a;">
{{printf "%.2f" .PreostaloSve}} din
{{dinari .PreostaloSve}} din
</div>
</div>
{{end}}
@@ -262,8 +269,8 @@
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
<td style="padding:9px 10px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{printf "%.2f" .CenaKomada}} din</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{dinari .CenaKomada}} din</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
<td style="padding:9px 10px;text-align:center;">
{{if index $.Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;">
@@ -84,7 +84,7 @@
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{.Telefon}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}
</div>
</div>
@@ -112,7 +112,7 @@
<div class="strana-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}—{{end}}</div>
<div class="strana-info">
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}{{.Telefon}}{{end}}
{{if .Telefon}}{{telefon .Telefon}}{{end}}
</div>
</div>
<div class="strana-kartica">
@@ -121,7 +121,7 @@
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{.Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}{{end}}
{{end}}
@@ -183,13 +183,13 @@
<tr>
<td>{{.ArtikalNaziv}}</td>
<td class="desno">{{.Kolicina}}</td>
<td class="desno">{{printf "%.2f" .CenaKomada}} din</td>
<td class="desno">{{printf "%.2f" .Ukupno}} din</td>
<td class="desno">{{dinari .CenaKomada}} din</td>
<td class="desno">{{dinari .Ukupno}} din</td>
</tr>
{{end}}
<tr class="ukupno-red">
<td colspan="3">Ukupno delovi</td>
<td class="desno">{{printf "%.2f" .UkupnoDelovi}} din</td>
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
</tr>
</tbody>
</table>
@@ -209,7 +209,7 @@
{{if gt .UkupnoDelovi 0.0}}
<tr>
<td>Ugrađeni delovi i materijal</td>
<td class="desno">{{printf "%.2f" .UkupnoDelovi}} din</td>
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
</tr>
{{end}}
{{if .ImaAvans}}
@@ -222,7 +222,7 @@
</table>
<div class="naplata-blok">
<div class="naplata-labela">Za naplatu pri preuzimanju:</div>
<div class="naplata-iznos">{{printf "%.2f" .PreostaloSve}} din</div>
<div class="naplata-iznos">{{dinari .PreostaloSve}} din</div>
</div>
</div>
{{end}}
@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Predračun — {{.Nalog.BrojNaloga}}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 13px; color: #111; background: #fff; }
.strana { max-width: 780px; margin: 0 auto; padding: 32px 40px; }
/* zaglavlje */
.zaglavlje { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 2px solid #111; gap: 20px; }
.firma-naziv { font-size: 20px; font-weight: 700; }
.firma-info { font-size: 11px; color: #555; margin-top: 4px; line-height: 1.7; }
.dok-naslov { text-align: right; }
.dok-tip { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #555; margin-bottom: 4px; }
.dok-broj { font-size: 22px; font-weight: 700; font-family: monospace; }
.dok-datum { font-size: 12px; color: #555; margin-top: 6px; }
/* strane */
.strane-blok { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.strana-kartica { padding: 12px 14px; border: 0.5px solid #ddd; border-radius: 6px; }
.strana-tip { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #888; margin-bottom: 6px; }
.strana-naziv { font-size: 14px; font-weight: 600; }
.strana-info { font-size: 12px; color: #555; margin-top: 3px; line-height: 1.6; }
/* odeljci */
.odeljak { margin-bottom: 18px; }
.odeljak-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: #555; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 0.5px solid #ccc; }
/* podaci */
.podaci-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.polje-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 3px; }
.polje-vrednost { font-size: 13px; font-weight: 500; }
.polje-vrednost-mono { font-family: monospace; font-size: 13px; font-weight: 500; }
.polje-tekst { font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
.prazno { color: #aaa; font-style: italic; }
/* tabela delova */
.tabela { width: 100%; border-collapse: collapse; margin-top: 6px; }
.tabela th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: #555; padding: 6px 8px; border-bottom: 1.5px solid #111; text-align: left; }
.tabela th.desno { text-align: right; }
.tabela td { padding: 7px 8px; border-bottom: 0.5px solid #ddd; font-size: 13px; vertical-align: middle; }
.tabela td.desno { text-align: right; }
.tabela .ukupno-red td { font-weight: 600; border-top: 1.5px solid #111; border-bottom: none; background: #fafafa; }
/* iznos procene */
.naplata-blok { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border: 1.5px solid #111; border-radius: 6px; margin-top: 12px; }
.naplata-labela { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
.naplata-iznos { font-size: 22px; font-weight: 700; }
/* rok važenja */
.rok-blok { padding: 10px 14px; border: 0.5px solid #fcd34d; background: #fffbeb; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; }
.rok-tekst { font-size: 12px; color: #92400e; font-weight: 500; }
.rok-datum { font-size: 15px; font-weight: 700; color: #92400e; }
/* potpisi */
.potpisi { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 36px; padding-top: 16px; border-top: 0.5px solid #ccc; }
.potpis-linija { border-top: 1px solid #888; margin-top: 44px; padding-top: 6px; font-size: 11px; color: #555; text-align: center; }
/* napomena na dnu */
.napomena-dno { margin-top: 20px; padding: 10px 14px; background: #f9fafb; border-left: 3px solid #ddd; font-size: 12px; color: #555; line-height: 1.6; }
@media print {
body { font-size: 12px; }
.strana { padding: 16px 20px; max-width: 100%; }
.dugme-stampa { display: none !important; }
@page { size: A4; margin: 12mm 14mm; }
}
.dugme-stampa { position: fixed; bottom: 24px; right: 24px; background: #111; color: #fff; border: none; padding: 12px 22px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
.dugme-stampa:hover { background: #333; }
</style>
</head>
<body>
<div class="strana">
<!-- zaglavlje -->
<div class="zaglavlje">
<div>
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}
</div>
</div>
<div style="display:flex;align-items:flex-start;gap:14px;">
<div class="dok-naslov">
<div class="dok-tip">Predračun / Ponuda</div>
<div class="dok-broj">{{.Nalog.BrojNaloga}}</div>
<div class="dok-datum">Datum izdavanja: {{.DatumIzdavanja.Format "02.01.2006."}}</div>
<div class="dok-datum">Važi do: {{.VaziDo.Format "02.01.2006."}}</div>
</div>
{{if .QRKod}}
<img src="data:image/png;base64,{{.QRKod}}" width="76" height="76"
alt="QR {{.Nalog.BrojNaloga}}"
style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">
{{end}}
</div>
</div>
<!-- izdavalac i klijent -->
<div class="strane-blok">
<div class="strana-kartica">
<div class="strana-tip">Izdaje</div>
<div class="strana-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}—{{end}}</div>
<div class="strana-info">
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}{{telefon .Telefon}}{{end}}
</div>
</div>
<div class="strana-kartica">
<div class="strana-tip">Za klijenta</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
</div>
<!-- uređaj na servisu -->
<div class="odeljak">
<div class="odeljak-naslov">Uređaj na servisu</div>
<div class="podaci-grid" style="margin-bottom:10px;">
<div>
<div class="polje-labela">Naziv uređaja</div>
<div class="polje-vrednost">{{.Nalog.Uredjaj}}</div>
</div>
<div>
<div class="polje-labela">Serijski broj</div>
<div class="polje-vrednost-mono">{{if .Nalog.SerijskiBroj}}{{.Nalog.SerijskiBroj}}{{else}}<span class="prazno"></span>{{end}}</div>
</div>
{{if .TehnicarNaziv}}
<div>
<div class="polje-labela">Tehničar</div>
<div class="polje-vrednost">{{.TehnicarNaziv}}</div>
</div>
{{end}}
</div>
</div>
<!-- opis kvara -->
<div class="odeljak">
<div class="odeljak-naslov">Utvrđeni kvar</div>
<div class="polje-tekst">{{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}<span class="prazno"></span>{{end}}</div>
</div>
<!-- predloženi delovi -->
{{if .ServisniDelovi}}
<div class="odeljak">
<div class="odeljak-naslov">Predloženi delovi i materijal</div>
<table class="tabela">
<thead>
<tr>
<th>Artikal</th>
<th class="desno" style="width:70px;">Kol.</th>
<th class="desno" style="width:120px;">Cena/kom</th>
<th class="desno" style="width:120px;">Ukupno</th>
</tr>
</thead>
<tbody>
{{range .ServisniDelovi}}
<tr>
<td>{{.ArtikalNaziv}}</td>
<td class="desno">{{.Kolicina}}</td>
<td class="desno">{{dinari .CenaKomada}} din</td>
<td class="desno">{{dinari .Ukupno}} din</td>
</tr>
{{end}}
<tr class="ukupno-red">
<td colspan="3">Ukupno delovi</td>
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
</tr>
</tbody>
</table>
</div>
{{end}}
<!-- procena troška -->
{{if .ImaCenuRada}}
<div class="odeljak">
<div class="odeljak-naslov">Procena troška</div>
<table class="tabela" style="margin-bottom:10px;">
<tbody>
{{if .CenaRaspon}}
<tr>
<td>Cena rada (procena)</td>
<td class="desno" style="width:200px;">{{dinari .CenaRadaOd}} {{dinari .CenaRadaDo}} din</td>
</tr>
{{else}}
<tr>
<td>Cena rada</td>
<td class="desno" style="width:200px;">{{dinari .CenaRada}} din</td>
</tr>
{{end}}
{{if gt .UkupnoDelovi 0.0}}
<tr>
<td>Predloženi delovi i materijal</td>
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
</tr>
{{end}}
</tbody>
</table>
<div class="naplata-blok">
<div class="naplata-labela">Procena ukupnog troška:</div>
{{if .CenaRaspon}}
<div class="naplata-iznos">{{dinari .UkupnoOd}} {{dinari .UkupnoDo}} din</div>
{{else}}
<div class="naplata-iznos">{{dinari .Ukupno}} din</div>
{{end}}
</div>
</div>
{{else}}
<div class="odeljak">
<div class="napomena-dno">Procena troška još nije utvrđena.</div>
</div>
{{end}}
<!-- rok važenja -->
<div class="odeljak">
<div class="rok-blok">
<div class="rok-tekst">Ova ponuda važi do:</div>
<div class="rok-datum">{{.VaziDo.Format "02.01.2006."}}</div>
</div>
</div>
{{if .Nalog.Napomena}}
<div class="napomena-dno"><strong>Napomena:</strong> {{.Nalog.Napomena}}</div>
{{end}}
<div class="napomena-dno">
Ovo je predračun (ponuda) i ne predstavlja fiskalni dokument. Navedene cene su procena na osnovu
utvrđenog kvara; konačan iznos može odstupati ako se tokom servisa otkriju dodatni kvarovi.
Servis se započinje nakon saglasnosti klijenta.
</div>
<!-- potpisi -->
<div class="potpisi">
<div>
<div class="potpis-linija">Ponudu izdao (firma)</div>
</div>
<div>
<div class="potpis-linija">Saglasan sa ponudom (klijent)</div>
</div>
</div>
</div>
<button class="dugme-stampa" onclick="window.print()">Štampaj</button>
</body>
</html>
+4 -4
View File
@@ -76,7 +76,7 @@
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{.Telefon}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}
</div>
</div>
@@ -188,13 +188,13 @@
<tr>
<td>{{.ArtikalNaziv}}</td>
<td class="desno">{{.Kolicina}}</td>
<td class="desno">{{printf "%.2f" .CenaKomada}} din</td>
<td class="desno">{{printf "%.2f" .Ukupno}} din</td>
<td class="desno">{{dinari .CenaKomada}} din</td>
<td class="desno">{{dinari .Ukupno}} din</td>
</tr>
{{end}}
<tr class="ukupno-red">
<td colspan="3">Ukupno delovi</td>
<td class="desno">{{printf "%.2f" .UkupnoDelovi}} din</td>
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
</tr>
</tbody>
</table>
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Status popravke — {{.Nalog.BrojNaloga}}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 14px; color: #111; background: #f5f5f7; min-height: 100vh; }
.omotac { max-width: 480px; margin: 0 auto; padding: 24px 16px 48px; }
/* zaglavlje firme */
.zaglavlje { text-align: center; margin-bottom: 24px; }
.firma-naziv { font-size: 18px; font-weight: 700; color: #111; }
.firma-info { font-size: 12px; color: #666; margin-top: 4px; line-height: 1.6; }
/* kartica naloga */
.kartica { background: #fff; border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.kartica-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #999; margin-bottom: 12px; }
.uredjaj { font-size: 22px; font-weight: 700; color: #111; line-height: 1.2; }
.broj-naloga { font-size: 13px; color: #888; margin-top: 4px; font-family: monospace; }
/* status bedž */
.status-bedz {
display: inline-block;
padding: 6px 14px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
margin-top: 14px;
}
.status-primljeno { background: #e8f0fe; color: #1a56db; }
.status-dijagnostika { background: #fef3c7; color: #b45309; }
.status-ceka { background: #fee2e2; color: #dc2626; }
.status-popravka { background: #fef3c7; color: #b45309; }
.status-zavrseno { background: #d1fae5; color: #059669; }
.status-preuzeto { background: #f3f4f6; color: #374151; }
/* napredak */
.napredak { margin: 16px 0 4px; }
.napredak-labela { font-size: 11px; color: #999; margin-bottom: 8px; }
.koraci { display: flex; gap: 4px; }
.korak {
flex: 1;
height: 5px;
border-radius: 3px;
background: #e5e7eb;
}
.korak.aktivan { background: #10b981; }
.korak.zavrseno { background: #10b981; }
.korak.tekuci { background: #f59e0b; }
/* red podataka */
.red { display: flex; justify-content: space-between; align-items: flex-start; padding: 10px 0; border-bottom: 0.5px solid #f0f0f0; gap: 12px; }
.red:last-child { border-bottom: none; }
.red-labela { font-size: 12px; color: #888; flex-shrink: 0; }
.red-vrednost { font-size: 13px; color: #111; font-weight: 500; text-align: right; }
/* cena */
.cena-blok { background: #f9fafb; border-radius: 10px; padding: 14px 16px; margin-top: 4px; }
.cena-od-do { display: flex; gap: 8px; align-items: center; }
.cena-od-do .cena-vrednost { font-size: 20px; font-weight: 700; color: #111; }
.cena-od-do .cena-sep { color: #bbb; }
.cena-labela { font-size: 11px; color: #888; margin-top: 2px; }
/* kontakt */
.kontakt { background: #fff; border-radius: 16px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.kontakt-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #999; margin-bottom: 12px; }
.kontakt-tel { font-size: 18px; font-weight: 700; color: #1a56db; text-decoration: none; display: block; }
.kontakt-adresa { font-size: 13px; color: #666; margin-top: 6px; line-height: 1.5; }
/* napomena za klijenta */
.napomena-blok { background: #fffbeb; border-radius: 10px; padding: 12px 14px; margin-top: 4px; font-size: 13px; color: #555; line-height: 1.5; }
</style>
</head>
<body>
<div class="omotac">
<!-- zaglavlje firme -->
<div class="zaglavlje">
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}Servis{{end}}</div>
{{if .Adresa}}<div class="firma-info">{{.Adresa}}</div>{{end}}
</div>
<!-- kartica: uređaj i status -->
<div class="kartica">
<div class="kartica-naslov">Vaš uređaj</div>
<div class="uredjaj">{{.Nalog.Uredjaj}}</div>
<div class="broj-naloga">Nalog: {{.Nalog.BrojNaloga}}</div>
{{$s := .Nalog.Status}}
{{if eq $s "Primljeno"}}
<span class="status-bedz status-primljeno">Primljeno</span>
{{else if eq $s "U dijagnostici"}}
<span class="status-bedz status-dijagnostika">U dijagnostici</span>
{{else if eq $s "Čeka delove"}}
<span class="status-bedz status-ceka">Čeka delove</span>
{{else if eq $s "U popravci"}}
<span class="status-bedz status-popravka">U popravci</span>
{{else if eq $s "Završeno"}}
<span class="status-bedz status-zavrseno">Završeno — možete preuzeti</span>
{{else if eq $s "Preuzeto"}}
<span class="status-bedz status-preuzeto">Preuzeto</span>
{{end}}
<!-- traka napretka -->
<div class="napredak">
<div class="napredak-labela">Napredak popravke</div>
<div class="koraci">
{{range $i, $korak := .SviStatusi}}
{{if eq $korak $s}}
<div class="korak tekuci"></div>
{{else if statusPre $korak $s $.SviStatusi}}
<div class="korak zavrseno"></div>
{{else}}
<div class="korak"></div>
{{end}}
{{end}}
</div>
</div>
</div>
<!-- kartica: detalji -->
<div class="kartica">
<div class="kartica-naslov">Detalji</div>
<div class="red">
<span class="red-labela">Datum prijema</span>
<span class="red-vrednost">{{.Nalog.DatumPrijema.Format "02.01.2006."}}</span>
</div>
{{if .Nalog.DatumZavrsetka}}
<div class="red">
<span class="red-labela">Datum završetka</span>
<span class="red-vrednost">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</span>
</div>
{{end}}
{{if .Nalog.SerijskiBroj}}
<div class="red">
<span class="red-labela">Serijski broj</span>
<span class="red-vrednost">{{.Nalog.SerijskiBroj}}</span>
</div>
{{end}}
{{if .Nalog.OpisKvara}}
<div class="red">
<span class="red-labela">Opis kvara</span>
<span class="red-vrednost">{{.Nalog.OpisKvara}}</span>
</div>
{{end}}
{{if .Nalog.GarancijaDo}}
<div class="red">
<span class="red-labela">Garancija do</span>
<span class="red-vrednost">{{.Nalog.GarancijaDo.Format "02.01.2006."}}</span>
</div>
{{end}}
<!-- procenjena cena -->
{{if or .Nalog.CenaOd .Nalog.CenaDo .Nalog.CenaKonacna}}
<div style="margin-top:12px;">
<div class="red-labela" style="margin-bottom:8px;">Procena cene</div>
<div class="cena-blok">
{{if .Nalog.CenaKonacna}}
<div class="cena-od-do">
<span class="cena-vrednost">{{formatBroj .Nalog.CenaKonacna}} din</span>
</div>
<div class="cena-labela">Konačna cena popravke</div>
{{else if and .Nalog.CenaOd .Nalog.CenaDo}}
<div class="cena-od-do">
<span class="cena-vrednost">{{formatBroj .Nalog.CenaOd}}</span>
<span class="cena-sep"></span>
<span class="cena-vrednost">{{formatBroj .Nalog.CenaDo}} din</span>
</div>
<div class="cena-labela">Procenjeni raspon cene</div>
{{else if .Nalog.CenaOd}}
<div class="cena-od-do">
<span class="cena-vrednost">od {{formatBroj .Nalog.CenaOd}} din</span>
</div>
<div class="cena-labela">Procenjena cena od</div>
{{end}}
</div>
</div>
{{end}}
{{if .Nalog.Napomena}}
<div style="margin-top:12px;">
<div class="red-labela" style="margin-bottom:6px;">Napomena</div>
<div class="napomena-blok">{{.Nalog.Napomena}}</div>
</div>
{{end}}
</div>
<!-- kontakt -->
{{if .Telefon}}
<div class="kontakt">
<div class="kontakt-naslov">Kontakt</div>
<a href="tel:{{.Telefon}}" class="kontakt-tel">{{telefon .Telefon}}</a>
{{if .Adresa}}<div class="kontakt-adresa">{{.Adresa}}</div>{{end}}
</div>
{{end}}
</div>
</body>
</html>
+5 -5
View File
@@ -20,7 +20,7 @@
</div>
<div style="text-align:right;">
<div style="font-size:12px;color:var(--tekst-slabi);">Ukupna vrednost zalihe</div>
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{printf "%.2f" .UkupnaVrednost}} din</div>
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{dinari .UkupnaVrednost}} din</div>
</div>
</div>
</div>
@@ -58,16 +58,16 @@
<td style="font-size:12px;color:var(--tekst-slabi);">{{if .Kategorija}}{{.Kategorija}}{{else}}—{{end}}</td>
<td style="text-align:right;font-weight:600;{{if le .Kolicina .KolicinMin}}color:#dc2626;{{end}}">{{.Kolicina}}</td>
<td style="text-align:right;font-size:12px;color:var(--tekst-slabi);">{{.KolicinMin}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{printf "%.2f" .NabavnaCena}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{printf "%.2f" .ProdajnaCena}}</td>
<td style="text-align:right;font-family:monospace;font-weight:500;">{{printf "%.2f" .VrednostZalihe}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .NabavnaCena}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .ProdajnaCena}}</td>
<td style="text-align:right;font-family:monospace;font-weight:500;">{{dinari .VrednostZalihe}}</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr style="border-top:1.5px solid var(--ivica);font-weight:600;">
<td colspan="7" style="padding:10px 12px;font-size:13px;">Ukupna vrednost zalihe</td>
<td style="text-align:right;padding:10px 12px;font-family:monospace;">{{printf "%.2f" .UkupnaVrednost}} din</td>
<td style="text-align:right;padding:10px 12px;font-family:monospace;">{{dinari .UkupnaVrednost}} din</td>
</tr>
</tfoot>
</table>
+109 -11
View File
@@ -28,6 +28,14 @@
{{if .AppPozadina}}
<style>
:root {
--app-blur-bg: {{.AppPozadinaBlurPozadine}}px;
--app-blur-bg-inset: {{if ne .AppPozadinaBlurPozadine "0"}}-20px{{else}}0{{end}};
--app-overlay: {{.AppPozadinaOpacity}}%;
--app-blur: {{.AppPozadinaBlur}}px;
--app-glass-sb: {{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}30%{{end}};
--app-glass-el: {{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}8%{{end}};
}
html {
background: url('{{.AppPozadina}}') center/cover fixed;
background-color: #1f2228;
@@ -36,9 +44,9 @@
body::before {
content: '';
position: fixed;
inset: {{if ne .AppPozadinaBlurPozadine "0"}}-20px{{else}}0{{end}};
inset: var(--app-blur-bg-inset);
background: url('{{.AppPozadina}}') center/cover;
filter: blur({{.AppPozadinaBlurPozadine}}px);
filter: blur(var(--app-blur-bg));
z-index: 0;
pointer-events: none;
}
@@ -46,18 +54,18 @@
content: '';
position: fixed;
inset: 0;
background: rgba(0,0,0,{{.AppPozadinaOpacity}}%);
background: rgba(0,0,0,var(--app-overlay));
z-index: 1;
pointer-events: none;
}
.raspored { position: relative; z-index: 2; }
.sidebar { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.3{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border-right: 1px solid rgba(255,255,255,0.12) !important; }
.sidebar { background: rgba(0,0,0,var(--app-glass-sb)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border-right: 1px solid rgba(255,255,255,0.12) !important; }
.sidebar .nav-stavka, .sidebar .logo-naziv, .sidebar .logo-podnazlov { text-shadow: 0 1px 3px rgba(0,0,0,0.8); color: rgba(255,255,255,0.95) !important; }
.sidebar .nav-stavka svg { color: rgba(255,255,255,0.95) !important; stroke: rgba(255,255,255,0.95) !important; }
.sidebar .nav-oznaka { text-shadow: 0 1px 3px rgba(0,0,0,0.8); color: rgba(255,255,255,0.7) !important; }
.topbar { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.08{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border-bottom: 1px solid rgba(255,255,255,0.12) !important; }
.topbar { background: rgba(0,0,0,var(--app-glass-el)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border-bottom: 1px solid rgba(255,255,255,0.12) !important; }
.topbar-naslov { color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 2px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.55); }
.kartica, .premesti-modal { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.08{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border: 1px solid rgba(255,255,255,0.12) !important; }
.kartica, .premesti-modal { background: rgba(0,0,0,var(--app-glass-el)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border: 1px solid rgba(255,255,255,0.12) !important; }
body:not([data-hover]) .kartica:hover { background: rgba(0,0,0,0.38) !important; }
body:not([data-hover]) .kartica:hover .dash-ikona { filter: brightness(0.55); }
.kartica p, .kartica span, .kartica h1, .kartica h2, .kartica h3, .kartica h4, .kartica label, .kartica td, .kartica th, .kartica li, .kartica a { color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 2px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.55); }
@@ -75,7 +83,7 @@
dobijaju sopstvenu staklenu podlogu da se vide bez obzira na sliku ispod */
.nazad-link, .btn-sekundarno, .btn-obrisi-ghost, .cek-filter {
background: rgba(0,0,0,0.28) !important;
backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px);
backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur));
border: 1px solid rgba(255,255,255,0.18) !important;
color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
@@ -110,7 +118,7 @@
</script>
{{end}}
</head>
<body{{if .LokalnaAnimacija}} data-animacija="{{.LokalnaAnimacija}}"{{end}}{{if .LokalniHover}} data-hover="{{.LokalniHover}}"{{end}}>
<body{{if .LokalnaAnimacija}} data-animacija="{{.LokalnaAnimacija}}"{{end}}{{if .LokalniHover}} data-hover="{{.LokalniHover}}"{{end}}{{if .LokalnaBrzinaAnimacije}} data-brzina-animacije="{{.LokalnaBrzinaAnimacije}}"{{end}}>
<div class="raspored">
<div class="sidebar-overlay" id="sidebar-overlay"></div>
{{template "sidebar" .}}
@@ -281,7 +289,12 @@
var svg = tip === 'greska'
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
t.innerHTML = svg + '<span>' + tekst + '</span>';
// SVG je statički literal (bezbedan za innerHTML); korisnički tekst ide
// preko textContent da se ne reinterpretira kao HTML (XSS zaštita)
t.innerHTML = svg;
var tekstSpan = document.createElement('span');
tekstSpan.textContent = tekst;
t.appendChild(tekstSpan);
// prilagođavamo poziciju za mobilne uređaje
t.style.bottom = '24px';
t.style.right = '16px';
@@ -307,6 +320,8 @@
function ntechInicijalizuj() {
ntechKonvertujPoruke();
var m = document.querySelector('meta[name="csrf-token"]');
if (m && m.content) {
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
@@ -316,6 +331,16 @@
f.appendChild(i);
});
}
// toast pri učitavanju stranice ako URL sadrži ?sacuvano=1 (cross-page redirect)
if (!window._ntechSacuvanoPrikazano && location.search.indexOf('sacuvano=1') !== -1) {
window._ntechSacuvanoPrikazano = true;
window.ntechToast('Sačuvano', 'uspeh');
var _p = new URLSearchParams(location.search);
_p.delete('sacuvano');
var _q = _p.toString() ? '?' + _p.toString() : '';
history.replaceState(null, '', location.pathname + _q + location.hash);
}
document.querySelectorAll('[data-potvrda]').forEach(function(el) {
if (el._potvrda) return;
el._potvrda = true;
@@ -323,14 +348,87 @@
e.preventDefault();
window._ntechPotvrdi(el.getAttribute('data-potvrda')).then(function(ok) {
if (!ok) return;
// dugme unutar forme — submit forme
var forma = el.closest('form');
if (forma) { forma.submit(); return; }
// link
if (el.href) { window.location.href = el.href; }
});
});
});
// AJAX submit — forme koje serveru vraćaju ?sacuvano= ostaju na stranici (scroll se ne gubi)
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (f._ajaxSave) return;
f._ajaxSave = true;
f.addEventListener('submit', function(e) {
// file upload i forme sa data-full-reload šalju se normalno
if (f.enctype === 'multipart/form-data' || f.hasAttribute('data-full-reload')) return;
e.preventDefault();
// prikazujemo dugme kao zauzeto
var btn = f.querySelector('[type="submit"]');
if (btn) btn.disabled = true;
// URLSearchParams šalje kao application/x-www-form-urlencoded
// što Go-ov r.FormValue() (i CSRF middleware) može da pročita
fetch(f.action || location.href, {
method: 'POST',
body: new URLSearchParams(new FormData(f)),
redirect: 'follow'
}).then(function(res) {
var finUrl = new URL(res.url);
var isStiPath = finUrl.pathname === location.pathname;
var imaSacuvano = finUrl.search.indexOf('sacuvano') !== -1;
if (isStiPath && imaSacuvano) {
// uspeh na istoj stranici — prikaži toast, ostani
window.ntechToast('Sačuvano', 'uspeh');
if (btn) btn.disabled = false;
// odmah primeni podešavanja koja menjaju globalne atribute body-ja
var anim = f.querySelector('[name="lokalna_animacija"]');
if (anim) {
if (anim.value) document.body.dataset.animacija = anim.value;
else delete document.body.dataset.animacija;
}
var hov = f.querySelector('[name="lokalni_hover"]');
if (hov) {
if (hov.value) document.body.dataset.hover = hov.value;
else delete document.body.dataset.hover;
}
var brzina = f.querySelector('[name="lokalna_brzina_animacije"]');
if (brzina) {
if (brzina.value) document.body.dataset.brzinaAnimacije = brzina.value;
else delete document.body.dataset.brzinaAnimacije;
}
// toggle „Prikaži logo u gornjoj traci" — pokaži/sakrij logo u topbaru bez reloda
var logoToggle = f.querySelector('[name="topbar_logo_slika"]');
if (logoToggle) {
var logoImg = document.querySelector('.topbar-logo');
if (logoImg) logoImg.classList.toggle('skriven', !logoToggle.checked);
}
// posle čuvanja stilova lične pozadine ažuriraj CSS custom properties
var bgBlur = f.querySelector('[name="lokalna_pozadina_blur"]');
if (bgBlur) {
var r = document.documentElement;
var bgBlurBg = parseInt(f.querySelector('[name="lokalna_pozadina_blur_pozadine"]').value) || 0;
var bgOp = parseInt(f.querySelector('[name="lokalna_pozadina_opacity"]').value) || 0;
var bgGlass = parseInt(f.querySelector('[name="lokalna_pozadina_glass_opacity"]').value) || 0;
var bgBlurV = parseInt(bgBlur.value) || 0;
r.style.setProperty('--app-blur-bg', bgBlurBg + 'px');
r.style.setProperty('--app-blur-bg-inset', bgBlurBg > 0 ? '-20px' : '0');
r.style.setProperty('--app-overlay', bgOp + '%');
r.style.setProperty('--app-blur', bgBlurV + 'px');
r.style.setProperty('--app-glass-sb', bgGlass + '%');
r.style.setProperty('--app-glass-el', bgGlass + '%');
}
// promena teme zahteva reload (menja se ceo CSS fajl)
if (f.querySelector('[name="lokalna_tema"]')) location.reload();
} else {
// redirect na drugu stranicu ili bez sacuvano — navigiraj normalno
location.href = res.url;
}
}).catch(function() {
// mrežna greška — pošalji formu normalno
f.submit();
});
});
});
}
if (!window._ntechCsrfDodato) {
window._ntechCsrfDodato = true;