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)
This commit is contained in:
2026-06-20 18:40:01 +02:00
parent a8f368ca06
commit b0250b2917
24 changed files with 930 additions and 106 deletions
+10 -7
View File
@@ -301,12 +301,14 @@ func main() {
r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr) r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr)
r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica) r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica)
r.Get("/magacin", h.Magacin) 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.Get("/magacin/novi", h.NoviArtikal)
r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal) 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.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) 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/obrisi/{id}", h.ObrisiArtikal)
r.With(doz("artikal.obrisi")).Get("/magacin/vrati/{id}", h.VratiArtikal)
r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal) 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")).Post("/magacin/promeni-cenu/{id}", h.PromeniCenuArtikla)
r.With(doz("artikal.izmeni")).Get("/nivelacije", h.Nivelacije) r.With(doz("artikal.izmeni")).Get("/nivelacije", h.Nivelacije)
@@ -338,16 +340,17 @@ func main() {
r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga) r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog) 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}", 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}/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}/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}/status", h.PromeniStatus)
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi", h.DodajDeloNalogu) 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.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga)
r.Get("/izvestaji", h.Izvestaji) r.Get("/izvestaji", h.Izvestaji)
r.Get("/izvestaji/prometni-list", h.PrometniListMagacina) r.Get("/izvestaji/prometni-list", h.PrometniListMagacina)
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj) r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
r.Get("/izvestaji/popis", h.Popis) r.Get("/izvestaji/popis", h.Popis)
r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis) r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
r.Get("/prodaja/nova", h.NovaProdaja) r.Get("/prodaja/nova", h.NovaProdaja)
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju) r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
+12 -2
View File
@@ -2,11 +2,16 @@ package db
import ( import (
"context" "context"
"errors"
"time" "time"
"ntech/internal/model" "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 // ArtikalRepository definiše operacije nad artiklima
type ArtikalRepository interface { type ArtikalRepository interface {
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error) Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
@@ -18,8 +23,12 @@ type ArtikalRepository interface {
AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error
PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
// SledecaSifra vraća predlog sledeće auto-šifre (npr. ART-00042) // Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju)
SledecaSifra(ctx context.Context) (string, error) 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 postavlja novu količinu artikla i upisuje korekciju u magacinske_promene
KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error
} }
@@ -79,6 +88,7 @@ type ArtikalFilter struct {
Pretraga string Pretraga string
KategorijaID *int64 KategorijaID *int64
SamoKriticni bool SamoKriticni bool
Arhivirani bool // true → vrati samo arhivirane; false (podrazumevano) → samo aktivne
Limit int Limit int
Offset int Offset int
} }
+100 -24
View File
@@ -3,10 +3,15 @@ package sqlite
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strconv"
"strings"
"ntech/internal/db" "ntech/internal/db"
"ntech/internal/model" "ntech/internal/model"
mosqlite "modernc.org/sqlite"
) )
// ArtikalRepo je SQLite implementacija ArtikalRepository interfejsa // 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) { func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]model.ArtikalSaKategorijom, error) {
upit := ` upit := `
SELECT 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.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 COALESCE(k.naziv, '') as kategorija_naziv, k.marza as kategorija_marza
FROM artikli a FROM artikli a
LEFT JOIN kategorije k ON a.kategorija_id = k.id LEFT JOIN kategorije k ON a.kategorija_id = k.id
@@ -45,7 +50,14 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
} }
if filter.SamoKriticni { 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" upit += " ORDER BY a.naziv ASC"
@@ -71,16 +83,18 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
var kategorijaID sql.NullInt64 var kategorijaID sql.NullInt64
var sifra, barkod sql.NullString var sifra, barkod sql.NullString
var marza, katMarza sql.NullFloat64 var marza, katMarza sql.NullFloat64
var arhiviran int
err := redovi.Scan( 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.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, &a.KategorijaNaziv, &katMarza,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: scan: %w", err) return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: scan: %w", err)
} }
a.Arhiviran = arhiviran == 1
if kategorijaID.Valid { if kategorijaID.Valid {
a.KategorijaID = &kategorijaID.Int64 a.KategorijaID = &kategorijaID.Int64
@@ -98,7 +112,8 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
a.KategorijaMarza = &katMarza.Float64 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) rezultat = append(rezultat, a)
} }
@@ -112,18 +127,20 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
var kategorijaID sql.NullInt64 var kategorijaID sql.NullInt64
var sifra, barkod sql.NullString var sifra, barkod sql.NullString
var marza sql.NullFloat64 var marza sql.NullFloat64
var arhiviran int
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, kategorija_id, sifra, barkod, naziv, opis, kolicina, kolicina_min, 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 lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa, arhiviran
FROM artikli WHERE id = ?`, id).Scan( 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.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 { if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
} }
a.Arhiviran = arhiviran == 1
if kategorijaID.Valid { if kategorijaID.Valid {
a.KategorijaID = &kategorijaID.Int64 a.KategorijaID = &kategorijaID.Int64
@@ -153,10 +170,10 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
rezultat, err := r.db.ExecContext(ctx, ` rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO artikli 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) nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin, 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.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena,
) )
if err != nil { if err != nil {
@@ -183,12 +200,12 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
UPDATE artikli SET UPDATE artikli SET
kategorija_id = ?, sifra = ?, barkod = ?, naziv = ?, opis = ?, kolicina = ?, kategorija_id = ?, sifra = ?, barkod = ?, naziv = ?, opis = ?, tip = ?, jedinica_mere = ?,
kolicina_min = ?, lokacija = ?, kolicina = ?, kolicina_min = ?, lokacija = ?,
nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, marza = ?, napomena = ? nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, marza = ?, napomena = ?
WHERE id = ?`, WHERE id = ?`,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Kolicina, a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere,
a.KolicinMin, a.Lokacija, a.Kolicina, a.KolicinMin, a.Lokacija,
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, a.ID, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, a.ID,
) )
if err != nil { if err != nil {
@@ -198,14 +215,43 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
return nil return nil
} }
// SledecaSifra vraća predlog sledeće auto-šifre u formatu ART-XXXXX // SledecaSifra vraća predlog sledeće auto-šifre u formatu PREFIKS-NNNN.
func (r *ArtikalRepo) SledecaSifra(ctx context.Context) (string, error) { // Prefiks je kôd kategorije (npr. KOMP) ili "ART" ako kategorija nema kôd ili nije zadata.
var n int64 // Brojač je najveći postojeći broj za taj prefiks + 1 (otporno na brisanje artikala).
err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM artikli").Scan(&n) 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 { if err != nil {
return "", fmt.Errorf("ntech: ArtikalRepo.SledecaSifra: %w", err) 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). // AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe).
@@ -229,16 +275,40 @@ func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategori
return nil 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 { func (r *ArtikalRepo) Obrisi(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM artikli WHERE id = ?", id) _, err := r.db.ExecContext(ctx, "DELETE FROM artikli WHERE id = ?", id)
if err != nil { 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 fmt.Errorf("ntech: ArtikalRepo.Obrisi: %w", err)
} }
return nil 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
}
// KorigujKolicinu postavlja novu količinu i upisuje korekciju u magacinske_promene // 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 { func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error {
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
@@ -290,7 +360,13 @@ func (r *ArtikalRepo) PrebrojiPoFilteru(ctx context.Context, filter db.ArtikalFi
} }
if filter.SamoKriticni { if filter.SamoKriticni {
upit += " AND a.kolicina <= a.kolicina_min" 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 var broj int
+24 -10
View File
@@ -20,7 +20,7 @@ func NovaKategorijaRepo(db *sql.DB) *KategorijaRepo {
// Lista vraća sve kategorije // Lista vraća sve kategorije
func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) { 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 { if err != nil {
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: %w", err) 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 var rezultat []model.Kategorija
for redovi.Next() { for redovi.Next() {
var k model.Kategorija var k model.Kategorija
var opis sql.NullString var opis, kod sql.NullString
var marza sql.NullFloat64 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) return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: scan: %w", err)
} }
if opis.Valid { if opis.Valid {
k.Opis = opis.String k.Opis = opis.String
} }
if kod.Valid {
k.Kod = kod.String
}
if marza.Valid { if marza.Valid {
k.Marza = &marza.Float64 k.Marza = &marza.Float64
} }
@@ -48,9 +51,13 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
// Kreiraj dodaje novu kategoriju // Kreiraj dodaje novu kategoriju
func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) { 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, rezultat, err := r.db.ExecContext(ctx,
"INSERT INTO kategorije (naziv, opis, marza) VALUES (?, ?, ?)", "INSERT INTO kategorije (naziv, opis, kod, marza) VALUES (?, ?, ?, ?)",
k.Naziv, k.Opis, k.Marza, k.Naziv, k.Opis, kod, k.Marza,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err) 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 // DohvatiID vraća jednu kategoriju po ID-u
func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) { func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) {
var k model.Kategorija var k model.Kategorija
var opis sql.NullString var opis, kod sql.NullString
var marza sql.NullFloat64 var marza sql.NullFloat64
err := r.db.QueryRowContext(ctx, err := r.db.QueryRowContext(ctx,
"SELECT id, naziv, opis, marza FROM kategorije WHERE id = ?", id). "SELECT id, naziv, opis, kod, marza FROM kategorije WHERE id = ?", id).
Scan(&k.ID, &k.Naziv, &opis, &marza) Scan(&k.ID, &k.Naziv, &opis, &kod, &marza)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err)
} }
if opis.Valid { if opis.Valid {
k.Opis = opis.String k.Opis = opis.String
} }
if kod.Valid {
k.Kod = kod.String
}
if marza.Valid { if marza.Valid {
k.Marza = &marza.Float64 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 // Izmeni ažurira naziv, opis i maržu postojeće kategorije
func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error { 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, _, err := r.db.ExecContext(ctx,
"UPDATE kategorije SET naziv = ?, opis = ?, marza = ? WHERE id = ?", "UPDATE kategorije SET naziv = ?, opis = ?, kod = ?, marza = ? WHERE id = ?",
k.Naziv, k.Opis, k.Marza, k.ID, k.Naziv, k.Opis, kod, k.Marza, k.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err) return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
+35 -21
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) { func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, ` redovi, err := r.db.QueryContext(ctx, `
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno, 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 FROM stavke_prodaje sp
JOIN artikli a ON a.id = sp.artikal_id JOIN artikli a ON a.id = sp.artikal_id
WHERE sp.nalog_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.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
&s.CenaPoKomadu, &s.Ukupno, &s.CenaPoKomadu, &s.Ukupno,
&s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv, &s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv,
&s.ArtikalNaziv, &s.ArtikalNaziv, &s.JedinicaMere,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err) 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 // provera stanja, smanjenje i insert stavki
for _, s := range stavke { for _, s := range stavke {
var naziv string var naziv, tip string
var stanjePre int var stanjePre int
err := tx.QueryRowContext(ctx, err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID, "SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &stanjePre) ).Scan(&naziv, &stanjePre, &tip)
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err) return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
} }
if stanjePre < s.Kolicina { // usluge i troškovi ne prate lager — ne proveravaju se i ne umanjuju
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv} pratiLager := tip == model.TipProizvod || tip == ""
} stanjePosle := stanjePre
if pratiLager {
stanjePosle := stanjePre - s.Kolicina if stanjePre < s.Kolicina {
_, err = tx.ExecContext(ctx, return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID, }
) stanjePosle = stanjePre - s.Kolicina
if err != nil { _, err = tx.ExecContext(ctx,
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err) "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 // 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) return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
} }
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja, // magacinsku promenu beležimo samo za artikle koji prate lager
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "") if pratiLager {
if err != nil { err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err) -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 { for _, p := range stavke {
var stanjePre int 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 { if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stanje: %w", err) 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 stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID, "UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
@@ -352,8 +365,9 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
redovi.Close() redovi.Close()
for _, p := range stavke { for _, p := range stavke {
// usluge i troškovi nemaju stanje — vraćamo samo proizvodima
_, err := tx.ExecContext(ctx, _, err := tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?", "UPDATE artikli SET kolicina = kolicina + ? WHERE id = ? AND (tip = 'proizvod' OR tip = '')",
p.kolicina, p.artikalID, p.kolicina, p.artikalID,
) )
if err != nil { if err != nil {
+15
View File
@@ -65,6 +65,7 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
k := &model.Kategorija{ k := &model.Kategorija{
Naziv: naziv, Naziv: naziv,
Opis: r.FormValue("opis"), Opis: r.FormValue("opis"),
Kod: normalizujKod(r.FormValue("kod")),
Marza: parsirajMarzu(r.FormValue("marza")), Marza: parsirajMarzu(r.FormValue("marza")),
} }
@@ -102,6 +103,7 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) {
ID: id, ID: id,
Naziv: naziv, Naziv: naziv,
Opis: r.FormValue("opis"), Opis: r.FormValue("opis"),
Kod: normalizujKod(r.FormValue("kod")),
Marza: parsirajMarzu(r.FormValue("marza")), Marza: parsirajMarzu(r.FormValue("marza")),
} }
@@ -126,6 +128,19 @@ func parsirajMarzu(s string) *float64 {
return &v 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 // ObrisiKategoriju briše kategoriju po ID-u
func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) { func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok {
+1 -1
View File
@@ -39,7 +39,7 @@ var saSidebar = []string{
// standalone su šabloni bez base layouta // standalone su šabloni bez base layouta
var standaloneIme = []string{ 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. // sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
+45 -4
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -20,6 +21,10 @@ type PodaciMagacina struct {
KategorijaIDStr string KategorijaIDStr string
Sacuvano bool Sacuvano bool
Obrisan bool Obrisan bool
Arhiviran bool
Vracen bool
Greska bool
PrikazArhivirani bool // true → lista prikazuje arhivirane umesto aktivnih
Premesten bool Premesten bool
StranicaBr int StranicaBr int
UkupnoStranica int UkupnoStranica int
@@ -40,6 +45,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
filter := db.ArtikalFilter{ filter := db.ArtikalFilter{
Pretraga: r.URL.Query().Get("pretraga"), Pretraga: r.URL.Query().Get("pretraga"),
SamoKriticni: r.URL.Query().Get("kriticni") == "1", SamoKriticni: r.URL.Query().Get("kriticni") == "1",
Arhivirani: r.URL.Query().Get("arhivirani") == "1",
} }
katIDStr := "" katIDStr := ""
@@ -51,7 +57,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
} }
} }
const pageSize = 100 const pageSize = 50
stranicaBr := 1 stranicaBr := 1
if p := r.URL.Query().Get("stranica"); p != "" { if p := r.URL.Query().Get("stranica"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 { if v, err := strconv.Atoi(p); err == nil && v > 0 {
@@ -96,6 +102,9 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
if filter.SamoKriticni { if filter.SamoKriticni {
queryDelići += "&kriticni=1" queryDelići += "&kriticni=1"
} }
if filter.Arhivirani {
queryDelići += "&arhivirani=1"
}
stranicaPrev := stranicaBr - 1 stranicaPrev := stranicaBr - 1
if stranicaPrev < 1 { if stranicaPrev < 1 {
@@ -114,6 +123,10 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
KategorijaIDStr: katIDStr, KategorijaIDStr: katIDStr,
Sacuvano: r.URL.Query().Get("sacuvano") == "1", Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "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", Premesten: r.URL.Query().Get("premesten") == "1",
StranicaBr: stranicaBr, StranicaBr: stranicaBr,
UkupnoStranica: ukupnoStranica, UkupnoStranica: ukupnoStranica,
@@ -169,18 +182,46 @@ func (h *Handler) ObrisiArtikal(w http.ResponseWriter, r *http.Request) {
} }
if err := h.Artikli.Obrisi(r.Context(), id); err != nil { 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 return
} }
http.Redirect(w, r, "/magacin?obrisan=1", http.StatusSeeOther) 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 // PodaciMagacinskeKartice su podaci za karticu jednog artikla
type PodaciMagacinskeKartice struct { type PodaciMagacinskeKartice struct {
model.PodaciStranice model.PodaciStranice
Artikal model.Artikal Artikal model.Artikal
Promene []model.MagacinskaPromenaSaDetaljem Promene []model.MagacinskaPromenaSaDetaljem
} }
// MagacinskaKartica prikazuje sve promene stanja za jedan artikal // MagacinskaKartica prikazuje sve promene stanja za jedan artikal
+47 -5
View File
@@ -37,10 +37,10 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
return return
} }
predlogSifre, err := h.Artikli.SledecaSifra(r.Context()) predlogSifre, err := h.Artikli.SledecaSifra(r.Context(), nil)
if err != nil { if err != nil {
slog.Error("greška pri generisanju predloga šifre", "err", err) slog.Error("greška pri generisanju predloga šifre", "err", err)
predlogSifre = "ART-00001" predlogSifre = "ART-0001"
} }
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
@@ -49,7 +49,7 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
h.renderujFormuArtikla(w, PodaciFormeArtikla{ h.renderujFormuArtikla(w, PodaciFormeArtikla{
PodaciStranice: ps, PodaciStranice: ps,
Kategorije: kategorije, Kategorije: kategorije,
Artikal: model.Artikal{Sifra: predlogSifre}, Artikal: model.Artikal{Sifra: predlogSifre, Tip: model.TipProizvod, JedinicaMere: "kom"},
Izmena: false, Izmena: false,
}) })
} }
@@ -94,9 +94,12 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
return return
} }
// ako korisnik nije uneo šifru, auto-generišemo po ID-u // ako korisnik nije uneo šifru, auto-generišemo po prefiksu kategorije
if artikal.Sifra == "" { if artikal.Sifra == "" {
autoSifra := fmt.Sprintf("ART-%05d", id) autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID)
if e != nil {
autoSifra = fmt.Sprintf("ART-%04d", id)
}
artikal.ID = id artikal.ID = id
artikal.Sifra = autoSifra artikal.Sifra = autoSifra
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
@@ -244,6 +247,22 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
artikal.Lokacija = r.FormValue("lokacija") artikal.Lokacija = r.FormValue("lokacija")
artikal.Napomena = r.FormValue("napomena") 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 — podrazumevano "kom"
artikal.JedinicaMere = r.FormValue("jedinica_mere")
if artikal.JedinicaMere == "" {
artikal.JedinicaMere = "kom"
}
if k := r.FormValue("kolicina"); k != "" { if k := r.FormValue("kolicina"); k != "" {
v, err := strconv.Atoi(k) v, err := strconv.Atoi(k)
if err != nil || v < 0 { if err != nil || v < 0 {
@@ -260,6 +279,12 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
artikal.KolicinMin = v 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 != "" { if c := r.FormValue("nabavna_cena"); c != "" {
v, err := strconv.ParseFloat(c, 64) v, err := strconv.ParseFloat(c, 64)
if err != nil || v < 0 { if err != nil || v < 0 {
@@ -295,6 +320,23 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
return artikal, "" 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 // renderujFormuArtikla renderuje HTML formu za artikal
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) { func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
h.renderujTemplate(w, "magacin_forma", podaci) h.renderujTemplate(w, "magacin_forma", podaci)
+16
View File
@@ -44,6 +44,7 @@ type PodaciPodesavanja struct {
BackupBrojKopija string BackupBrojKopija string
KalkulacijaMarza string KalkulacijaMarza string
ServisGarancijaMeseci string ServisGarancijaMeseci string
PredracunRokDana string
LoginPozadina string LoginPozadina string
LoginPozadinaOpacity string LoginPozadinaOpacity string
LoginPozadinaBlurPozadine 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) 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"), BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"), KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"), ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"),
PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"),
}, nil }, nil
} }
+148
View File
@@ -782,6 +782,154 @@ 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 := "http"
if r.TLS != nil {
nalogURL += "s"
}
nalogURL += "://" + r.Host + "/status/" + 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 // PromeniStatus obrađuje POST /servis/{id}/status i menja samo status naloga
func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id")) id, err := parseID(chi.URLParam(r, "id"))
+17
View File
@@ -2,6 +2,13 @@ package model
import "time" 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 // Artikal predstavlja jedan artikal u magacinu
type Artikal struct { type Artikal struct {
ID int64 ID int64
@@ -10,6 +17,8 @@ type Artikal struct {
Barkod string Barkod string
Naziv string Naziv string
Opis string Opis string
Tip string // proizvod | usluga | trosak
JedinicaMere string // kom, sat, set, m, l, kg ...
Kolicina int Kolicina int
KolicinMin int KolicinMin int
Lokacija string Lokacija string
@@ -19,6 +28,13 @@ type Artikal struct {
Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno
Napomena string Napomena string
DatumUnosa time.Time 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 // CenaBezPdv izračunava prodajnu cenu bez PDV-a
@@ -36,6 +52,7 @@ type Kategorija struct {
ID int64 ID int64
Naziv string Naziv string
Opis 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 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 // ProdajniNalog predstavlja zaglavlje jedne prodaje
type ProdajniNalog struct { type ProdajniNalog struct {
ID int64 ID int64
KlijentID *int64 KlijentID *int64
BrojNaloga string BrojNaloga string
Napomena string Napomena string
Ukupno float64 Ukupno float64
NacinPlacanja string NacinPlacanja string
Stornirano bool Stornirano bool
RazlogStorniranja string RazlogStorniranja string
Datum time.Time Datum time.Time
} }
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje // StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
@@ -38,4 +38,5 @@ type ProdajniNalogSaDetaljem struct {
type StavkaProdajeSaArtiklom struct { type StavkaProdajeSaArtiklom struct {
StavkaProdaje StavkaProdaje
ArtikalNaziv string 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';
+21 -6
View File
@@ -515,6 +515,9 @@ body {
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
text-decoration: none; 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; transition: opacity 0.2s;
} }
.btn-primarno-malo:hover { opacity: 0.85; } .btn-primarno-malo:hover { opacity: 0.85; }
@@ -536,6 +539,10 @@ body {
.btn-sekundarno:hover { background: var(--pozadina); color: var(--tekst-glavni); } .btn-sekundarno:hover { background: var(--pozadina); color: var(--tekst-glavni); }
/* crveno dugme za brisanje u tabelama */ /* 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 { .btn-obrisi-malo {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -548,6 +555,7 @@ body {
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
white-space: nowrap;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
.btn-obrisi-malo:hover { opacity: 0.8; } .btn-obrisi-malo:hover { opacity: 0.8; }
@@ -1070,6 +1078,16 @@ select {
animation: none; 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; }
/* korisnikova preferencija animacije: body[data-animacija] nadjačava podrazumevano. /* korisnikova preferencija animacije: body[data-animacija] nadjačava podrazumevano.
Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */ Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */
[data-animacija="bez"] .animiraj, [data-animacija="bez"] .animiraj,
@@ -1121,12 +1139,9 @@ select {
.tabela tbody tr:nth-child(20) { animation-delay: 0.80s; } .tabela tbody tr:nth-child(20) { animation-delay: 0.80s; }
/* content-visibility: auto browser preskače render elemenata van viewport-a. /* content-visibility: auto browser preskače render elemenata van viewport-a.
Ovo rešava problem sa 1000+ redova gde skrolovanje postaje prazno Stranica je ograničena na 50 redova, pa redovi tabele ne koriste ovu optimizaciju:
dok browser ne stigne da iscrta sve. */ procenjena visina (contain-intrinsic-size) se nije poklapala sa stvarnom, pa su
.tabela tbody tr { redovi secali" pri skrolu kad ih browser tek tada izmeri. */
content-visibility: auto;
contain-intrinsic-size: 48px;
}
[class*="-kartice"] > .animiraj, [class*="-kartice"] > .animiraj,
.klijenti-kartice > .animiraj { .klijenti-kartice > .animiraj {
content-visibility: auto; content-visibility: auto;
+16 -1
View File
@@ -47,6 +47,12 @@
<input type="text" name="naziv" placeholder="npr. Memorija, Diskovi, Kablovi..." <input type="text" name="naziv" placeholder="npr. Memorija, Diskovi, Kablovi..."
style="width:100%;"> style="width:100%;">
</div> </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> <div>
<label class="polje-labela">Opis</label> <label class="polje-labela">Opis</label>
<input type="text" name="opis" placeholder="Kratak opis kategorije..." <input type="text" name="opis" placeholder="Kratak opis kategorije..."
@@ -76,7 +82,10 @@
{{range .Kategorije}} {{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 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="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}} {{if .Opis}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.Opis}}</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.Opis}}</div>
{{end}} {{end}}
@@ -98,6 +107,12 @@
<input type="text" name="naziv" value="{{.Naziv}}" required <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;"> 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>
<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;">
</div>
<div> <div>
<label class="polje-labela">Opis</label> <label class="polje-labela">Opis</label>
<input type="text" name="opis" value="{{.Opis}}" <input type="text" name="opis" value="{{.Opis}}"
+62 -9
View File
@@ -11,6 +11,15 @@
{{if .Obrisan}} {{if .Obrisan}}
<div class="poruka-uspeh">Artikal je uspešno obrisan.</div> <div class="poruka-uspeh">Artikal je uspešno obrisan.</div>
{{end}} {{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}} {{if .Premesten}}
<div class="poruka-uspeh">Artikal je premešten u drugu kategoriju.</div> <div class="poruka-uspeh">Artikal je premešten u drugu kategoriju.</div>
{{end}} {{end}}
@@ -26,10 +35,10 @@
</div> </div>
<!-- pretraga i filteri — interaktivna pretraga (hx-trigger) --> <!-- pretraga i filteri — interaktivna pretraga (hx-trigger) -->
<form method="GET" action="/magacin" class="kolona" style="gap:10px;" <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"> 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}}" <input type="text" name="pretraga" value="{{.Filter.Pretraga}}"
placeholder="Pretraži artikle..." placeholder="Pretraži po nazivu, šifri, barkodu, lokaciji..."
style="width:100%;" style="width:100%;"
hx-trigger="keyup changed delay:300ms, search" hx-trigger="keyup changed delay:300ms, search"
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true"> hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
@@ -48,6 +57,12 @@
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true"> hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
Samo kritični Samo kritični
</label> </label>
<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>
<button type="submit" class="btn-primarno"> <button type="submit" class="btn-primarno">
Traži Traži
</button> </button>
@@ -63,6 +78,7 @@
<table class="magacin-tabela tabela"> <table class="magacin-tabela tabela">
<thead> <thead>
<tr style="border-bottom:0.5px solid var(--ivica);"> <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);">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: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> <th style="padding:12px 16px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-jak);">Količina</th>
@@ -74,14 +90,19 @@
<tbody> <tbody>
{{range .Artikli}} {{range .Artikli}}
<tr class="animiraj red-tabele"> <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);"> <td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}} {{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}
</td> </td>
<td style="padding:12px 16px;text-align:center;"> <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}};"> <span style="font-size:14px;font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">
{{.Kolicina}} {{.Kolicina}}
</span> </span>
{{else}}
<span style="font-size:12px;color:var(--tekst-sporedni);">{{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}}</span>
{{end}}
</td> </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);">{{printf "%.0f" .ProdajnaCena}} din</td>
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);"> <td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
@@ -89,9 +110,6 @@
</td> </td>
<td style="padding:12px 16px;text-align:center;"> <td style="padding:12px 16px;text-align:center;">
<div style="display:flex;align-items:center;justify-content:center;gap:8px;"> <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"}} {{if index $.Dozvole "artikal.izmeni"}}
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo"> <a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">
Izmeni Izmeni
@@ -102,11 +120,18 @@
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "tab")}} {{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "tab")}}
{{end}}{{end}} {{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}} {{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" <a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?"> data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši Obriši
</a> </a>
{{end}} {{end}}
{{end}}
</div> </div>
</td> </td>
</tr> </tr>
@@ -128,13 +153,15 @@
<div class="kartica magacin-kartica animiraj"> <div class="kartica magacin-kartica animiraj">
<div class="red-izmedju" style="align-items:flex-start;gap:12px;margin-bottom:10px;flex-wrap:wrap;"> <div class="red-izmedju" style="align-items:flex-start;gap:12px;margin-bottom:10px;flex-wrap:wrap;">
<div> <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}} {{if .KategorijaNaziv}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.KategorijaNaziv}}</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.KategorijaNaziv}}</div>
{{end}} {{end}}
</div> </div>
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;"> <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"}} {{if index $.Dozvole "artikal.izmeni"}}
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">Izmeni</a> <a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">Izmeni</a>
{{template "promeniCenuMeni" (dict "ID" .ID "Cena" .ProdajnaCena)}} {{template "promeniCenuMeni" (dict "ID" .ID "Cena" .ProdajnaCena)}}
@@ -143,17 +170,29 @@
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}} {{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}}
{{end}}{{end}} {{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}} {{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" <a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?"> data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši Obriši
</a> </a>
{{end}} {{end}}
{{end}}
</div> </div>
</div> </div>
<div style="display:flex;flex-wrap:wrap;gap:10px;"> <div style="display:flex;flex-wrap:wrap;gap:10px;">
<div class="pomocni-tekst"> <div class="pomocni-tekst">
{{if .PratiLager}}
<span style="color:var(--tekst-glavni);font-weight:500;">Količina:</span> <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>
<div class="pomocni-tekst"> <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> {{printf "%.0f" .ProdajnaCena}} din
@@ -190,6 +229,20 @@
</div><!-- kraj #magacin-rezultati --> </div><!-- kraj #magacin-rezultati -->
</div> </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');
}
});
</script>
{{end}} {{end}}
{{/* padajući meni za premeštanje artikla — prima dict {ID, Kategorije}; koristi se i u tabeli i u mobilnoj kartici */}} {{/* padajući meni za premeštanje artikla — prima dict {ID, Kategorije}; koristi se i u tabeli i u mobilnoj kartici */}}
+62 -6
View File
@@ -29,8 +29,8 @@
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> <div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div> <div>
<label class="polje-labela">Šifra artikla</label> <label class="polje-labela">Šifra artikla</label>
<input type="text" name="sifra" value="{{.Artikal.Sifra}}" <input type="text" name="sifra" id="sifra-input" value="{{.Artikal.Sifra}}"
placeholder="npr. ART-00001" placeholder="npr. KOMP-0001"
style="width:100%;font-family:monospace;"> 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 style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Ako ostaviš prazno, šifra se automatski dodeljuje.</div>
</div> </div>
@@ -53,6 +53,32 @@
style="width:100%;"> style="width:100%;">
</div> </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 --> <!-- kategorija -->
<div> <div>
<label class="polje-labela">Kategorija</label> <label class="polje-labela">Kategorija</label>
@@ -72,8 +98,8 @@
style="width:100%;resize:vertical;">{{.Artikal.Opis}}</textarea> style="width:100%;resize:vertical;">{{.Artikal.Opis}}</textarea>
</div> </div>
<!-- količina i minimum --> <!-- količina i minimum (samo za proizvode) -->
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> <div class="forma-grid-2" data-lager style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div> <div>
<label class="polje-labela">Količina na stanju</label> <label class="polje-labela">Količina na stanju</label>
<input type="number" name="kolicina" value="{{.Artikal.Kolicina}}" min="0" style="width:100%;"> <input type="number" name="kolicina" value="{{.Artikal.Kolicina}}" min="0" style="width:100%;">
@@ -102,8 +128,8 @@
placeholder="prazno = po kategoriji / globalna"> placeholder="prazno = po kategoriji / globalna">
</div> </div>
<!-- lokacija --> <!-- lokacija (samo za proizvode) -->
<div> <div data-lager>
<label class="polje-labela">Lokacija u magacinu</label> <label class="polje-labela">Lokacija u magacinu</label>
<input type="text" name="lokacija" value="{{.Artikal.Lokacija}}" <input type="text" name="lokacija" value="{{.Artikal.Lokacija}}"
placeholder="npr. Polica A3, Kutija 2..." placeholder="npr. Polica A3, Kutija 2..."
@@ -130,4 +156,34 @@
</form> </form>
</div> </div>
</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; });
});
}
})();
</script>
{{end}} {{end}}
@@ -33,6 +33,18 @@
</div> </div>
</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>
<div style="display:flex;justify-content:flex-end;margin-top:20px;"> <div style="display:flex;justify-content:flex-end;margin-top:20px;">
+1 -1
View File
@@ -81,7 +81,7 @@
{{range .Stavke}} {{range .Stavke}}
<tr> <tr>
<td>{{.ArtikalNaziv}}</td> <td>{{.ArtikalNaziv}}</td>
<td>{{.Kolicina}}</td> <td>{{.Kolicina}} {{.JedinicaMere}}</td>
<td>{{printf "%.2f" .CenaPoKomadu}} din</td> <td>{{printf "%.2f" .CenaPoKomadu}} din</td>
<td>{{printf "%.2f" .Ukupno}} din</td> <td>{{printf "%.2f" .Ukupno}} din</td>
</tr> </tr>
@@ -61,6 +61,9 @@
<a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno"> <a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno">
Radni nalog Radni nalog
</a> </a>
<a href="/servis/{{.Nalog.ID}}/predracun" target="_blank" class="btn-sekundarno">
Predračun
</a>
{{end}} {{end}}
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}} {{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno"> <a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
@@ -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}}<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}}{{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: {{.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">{{printf "%.2f" .CenaKomada}} din</td>
<td class="desno">{{printf "%.2f" .Ukupno}} din</td>
</tr>
{{end}}
<tr class="ukupno-red">
<td colspan="3">Ukupno delovi</td>
<td class="desno">{{printf "%.2f" .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;">{{printf "%.2f" .CenaRadaOd}} {{printf "%.2f" .CenaRadaDo}} din</td>
</tr>
{{else}}
<tr>
<td>Cena rada</td>
<td class="desno" style="width:200px;">{{printf "%.2f" .CenaRada}} din</td>
</tr>
{{end}}
{{if gt .UkupnoDelovi 0.0}}
<tr>
<td>Predloženi delovi i materijal</td>
<td class="desno">{{printf "%.2f" .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">{{printf "%.2f" .UkupnoOd}} {{printf "%.2f" .UkupnoDo}} din</div>
{{else}}
<div class="naplata-iznos">{{printf "%.2f" .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>