Compare commits

...

14 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
51 changed files with 2064 additions and 324 deletions
+12 -7
View File
@@ -301,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)
@@ -338,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)
+36 -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
@@ -127,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)
}
+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)
+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)
}
}
}
+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 {
+108 -1
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"math"
"net/http"
"strings"
)
var bazniSabloni = []string{
@@ -39,7 +40,7 @@ var saSidebar = []string{
// standalone su šabloni bez base layouta
var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_status_javni",
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_predracun", "servis_status_javni",
}
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
@@ -53,6 +54,17 @@ var sablonskeFunkcije = template.FuncMap{
}
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
@@ -172,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)
}
+16
View File
@@ -44,6 +44,7 @@ type PodaciPodesavanja struct {
BackupBrojKopija string
KalkulacijaMarza string
ServisGarancijaMeseci string
PredracunRokDana string
LoginPozadina string
LoginPozadinaOpacity string
LoginPozadinaBlurPozadine string
@@ -332,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)
}
@@ -700,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 -1
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
}
+160 -10
View File
@@ -644,11 +644,7 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
}
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
nalogURL := scheme + "://" + r.Host + "/status/" + nalog.JavniToken
nalogURL := qrNalogURL(r, nalog.JavniToken)
var qrKod string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKod = base64.StdEncoding.EncodeToString(png)
@@ -754,11 +750,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
}
}
nalogURL := "http"
if r.TLS != nil {
nalogURL += "s"
}
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
nalogURL := qrNalogURL(r, nalog.JavniToken)
var qrKodOtpr string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKodOtpr = base64.StdEncoding.EncodeToString(png)
@@ -782,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"))
@@ -843,3 +979,17 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
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
}
+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
}
+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
}
+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 != '';
+49
View File
@@ -515,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; }
@@ -536,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;
@@ -548,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; }
@@ -1070,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,
@@ -1109,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/
@@ -1118,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
+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
@@ -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>
+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>
+8 -5
View File
@@ -61,6 +61,9 @@
<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">
@@ -185,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}}
@@ -193,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}}
@@ -207,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}}
@@ -266,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>
@@ -198,7 +198,7 @@
{{if .Telefon}}
<div class="kontakt">
<div class="kontakt-naslov">Kontakt</div>
<a href="tel:{{.Telefon}}" class="kontakt-tel">{{.Telefon}}</a>
<a href="tel:{{.Telefon}}" class="kontakt-tel">{{telefon .Telefon}}</a>
{{if .Adresa}}<div class="kontakt-adresa">{{.Adresa}}</div>{{end}}
</div>
{{end}}
+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>
+6 -1
View File
@@ -289,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';