Merge feature/knjigovodstvo: knjigovodstveni modul

Profil firme i moduli, PDV evidencija (KIR/KPR) sa auto-punjenjem, PDV obračun + PP-PDV,
uvoz robe (006/106), šifarnik PDV stopa, kalkulacija prodajne cene (marža po
kategoriji/artiklu, zavisni troškovi, dvosmerni izračun) i nivelacija uz trag.
# Conflicts:
#	web/templates/stranice/magacin.html
#	web/templates/stranice/nabavka_forma.html
This commit is contained in:
2026-06-14 17:49:33 +02:00
70 changed files with 4640 additions and 152 deletions
+8 -1
View File
@@ -40,6 +40,12 @@ The goal is simple: everything the repair shop needs to track is located in one
- Service orders — intake, status bar, costs, receipt - Service orders — intake, status bar, costs, receipt
- Sales orders — items, calculation, receipt with company and client details - Sales orders — items, calculation, receipt with company and client details
- Procurement — records of purchases from suppliers - Procurement — records of purchases from suppliers
- Sales price calculation on procurement — markup (global, per category, per item), landed costs (customs, shipping...) allocated across items, two-way markup↔price computation; respects VAT-payer status
- Price revaluation (nivelacija) — sales price changes with an audit trail (old→new, reason, source, user)
- Company profile and modules — features toggle based on company type and VAT-payer status
- VAT records (KIR/KPR) — books of issued and received invoices, auto-filled from sales and procurement
- VAT calculation per period + mapping to the PP-PDV form; imports (customs declaration) tracked in fields 006/106
- VAT rate code list
- Clients and suppliers — contact database - Clients and suppliers — contact database
- Reminders — records with deadlines - Reminders — records with deadlines
- Reports — revenue overview, inventory status - Reports — revenue overview, inventory status
@@ -55,7 +61,8 @@ The goal is simple: everything the repair shop needs to track is located in one
### Planned ### Planned
- Fiscalization and VAT calculation (specification in Project.md) - Fiscalization (ESIR/PFR) — specification in Project.md
- KPO book and double-entry bookkeeping (optional, later phase)
- PostgreSQL support (for multi-user environments) - PostgreSQL support (for multi-user environments)
- WebAuthn / Passkey login (database schema is already prepared) - WebAuthn / Passkey login (database schema is already prepared)
- Notifications (email / WhatsApp) — deferred to a later phase - Notifications (email / WhatsApp) — deferred to a later phase
+8 -1
View File
@@ -40,6 +40,12 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
- Servisni nalozi — prijem, statusna traka, troškovi, priznanica - Servisni nalozi — prijem, statusna traka, troškovi, priznanica
- Prodajni nalozi — stavke, obračun, priznanica sa podacima firme i klijenta - Prodajni nalozi — stavke, obračun, priznanica sa podacima firme i klijenta
- Nabavke — evidencija nabavki od dobavljača - Nabavke — evidencija nabavki od dobavljača
- Kalkulacija prodajne cene pri nabavci — marža (globalna, po kategoriji i po artiklu), zavisni troškovi (carina, prevoz...) sa raspodelom na stavke, dvosmerni izračun marža↔prodajna; poštuje status PDV obveznika
- Nivelacija — promena prodajne cene uz trag (istorija promena: stara→nova, razlog, izvor, korisnik)
- Profil firme i moduli — funkcije se uključuju prema tipu firme i statusu PDV obveznika
- PDV evidencija (KIR/KPR) — knjige izdatih i primljenih računa, automatsko punjenje iz prodaje i nabavke
- PDV obračun za period + mapiranje na obrazac PP-PDV; uvoz robe (JCI) se vodi u poljima 006/106
- Šifarnik PDV stopa
- Klijenti i dobavljači — baza kontakata - Klijenti i dobavljači — baza kontakata
- Podsetnici — evidencija sa rokom - Podsetnici — evidencija sa rokom
- Izveštaji — pregled prihoda, stanje magacina - Izveštaji — pregled prihoda, stanje magacina
@@ -55,7 +61,8 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
### Planirano ### Planirano
- Fiskalizacija i PDV obračun (specifikacija u Project.md) - Fiskalizacija (ESIR/PFR) — specifikacija u Project.md
- KPO knjiga i dvojno knjigovodstvo (opciono, kasnija faza)
- Podrška za PostgreSQL (za višekorisničko okruženje) - Podrška za PostgreSQL (za višekorisničko okruženje)
- WebAuthn / Passkey prijava (šema baze je pripremljena) - WebAuthn / Passkey prijava (šema baze je pripremljena)
- Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu - Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu
+44 -7
View File
@@ -8,9 +8,9 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
"mime"
"net/http" "net/http"
"os" "os"
"mime"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
@@ -87,12 +87,14 @@ func main() {
db, err := sqlite.OtvoriDB(putanjaBaze) db, err := sqlite.OtvoriDB(putanjaBaze)
if err != nil { if err != nil {
slog.Error("Greška pri otvaranju baze", "error", err); os.Exit(1) slog.Error("Greška pri otvaranju baze", "error", err)
os.Exit(1)
} }
defer db.Close() defer db.Close()
if err := sqlite.PokreniMigracije(db, migrFS); err != nil { if err := sqlite.PokreniMigracije(db, migrFS); err != nil {
slog.Error("Greška pri migracijama", "error", err); os.Exit(1) slog.Error("Greška pri migracijama", "error", err)
os.Exit(1)
} }
slog.Info("migracije uspešno izvršene") slog.Info("migracije uspešno izvršene")
@@ -111,7 +113,8 @@ func main() {
// ključ za šifrovanje TOTP tajni u mirovanju (AES-256-GCM) // ključ za šifrovanje TOTP tajni u mirovanju (AES-256-GCM)
totpKljuc, err := ucitajTotpKljuc() totpKljuc, err := ucitajTotpKljuc()
if err != nil { if err != nil {
slog.Error("Greška pri učitavanju ključa za TOTP", "error", err); os.Exit(1) slog.Error("Greška pri učitavanju ključa za TOTP", "error", err)
os.Exit(1)
} }
// jednokratno šifruj eventualne stare TOTP tajne koje su ostale kao čist tekst // jednokratno šifruj eventualne stare TOTP tajne koje su ostale kao čist tekst
@@ -137,7 +140,8 @@ func main() {
if os.Getenv("NTECH_ENV") == "production" { if os.Getenv("NTECH_ENV") == "production" {
kes, err := handler.KreirajKes(templFS) kes, err := handler.KreirajKes(templFS)
if err != nil { if err != nil {
slog.Error("Greška pri kreiranju keša šablona", "error", err); os.Exit(1) slog.Error("Greška pri kreiranju keša šablona", "error", err)
os.Exit(1)
} }
h.Templates = kes h.Templates = kes
slog.Info("keš šablona kreiran", "broj", len(kes)) slog.Info("keš šablona kreiran", "broj", len(kes))
@@ -221,6 +225,20 @@ func main() {
return ntechmw.RequireDozvolaMut(h.DozvoleRepo.ImaDozvolu, akcija) return ntechmw.RequireDozvolaMut(h.DozvoleRepo.ImaDozvolu, akcija)
} }
// modul vraća middleware koji propušta zahtev samo ako je zakonski modul
// uključen za firmu (profil firme). Sloj IZNAD RBAC-a — zahtev mora proći
// i „modul uključen" (ovo) i „korisnik sme" (doz/zahtevajDozvolu).
proveriModul := func(ctx context.Context, m string) bool {
pod, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB)
if err != nil {
return false
}
return config.ModulUkljucen(pod, m)
}
modul := func(m string) func(http.Handler) http.Handler {
return ntechmw.RequireModul(proveriModul, m)
}
r.Get("/", func(w http.ResponseWriter, r *http.Request) { r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dashboard", http.StatusFound) http.Redirect(w, r, "/dashboard", http.StatusFound)
}) })
@@ -229,6 +247,10 @@ func main() {
r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste) r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste)
r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled) r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled)
r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem) r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem)
r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/logo", h.OtpremiLogo) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/logo", h.OtpremiLogo)
r.With(doz("podesavanja.login_pozadina")).Post("/podesavanja/login-pozadina", h.OtpremiLoginPozadinu) r.With(doz("podesavanja.login_pozadina")).Post("/podesavanja/login-pozadina", h.OtpremiLoginPozadinu)
@@ -237,6 +259,18 @@ func main() {
r.Get("/podesavanja/backup", h.BackupBaze) r.Get("/podesavanja/backup", h.BackupBaze)
r.With(doz("backup.pokreni")).Post("/podesavanja/backup/vrati", h.VratiBackup) r.With(doz("backup.pokreni")).Post("/podesavanja/backup/vrati", h.VratiBackup)
// PDV evidencija — KIR (knjiga izdatih računa). Dostupno samo kada je modul
// „pdv" uključen za firmu (RequireModul), uz RBAC dozvolu pdv.*.
r.With(modul("pdv")).Get("/pdv/kir", h.PdvKir)
r.With(modul("pdv")).Get("/pdv/kir/nova", h.NoviPdvKir)
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/nova", h.SacuvajPdvKir)
r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kir/obrisi/{id}", h.ObrisiPdvKir)
r.With(modul("pdv")).Get("/pdv/kpr", h.PdvKpr)
r.With(modul("pdv")).Get("/pdv/kpr/nova", h.NoviPdvKpr)
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kpr/nova", h.SacuvajPdvKpr)
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", h.Magacin)
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)
@@ -244,8 +278,11 @@ func main() {
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.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")).Get("/nivelacije", h.Nivelacije)
r.Get("/magacin/kategorije", h.Kategorije) r.Get("/magacin/kategorije", h.Kategorije)
r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju)
r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju)
r.With(doz("kategorija.obrisi")).Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) r.With(doz("kategorija.obrisi")).Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka)
@@ -327,7 +364,8 @@ func main() {
slog.Info("NTech pokrenut", "port", port) slog.Info("NTech pokrenut", "port", port)
err = http.ListenAndServe(":"+port, r) err = http.ListenAndServe(":"+port, r)
if err != nil { if err != nil {
slog.Error("port je zauzet ili nije dostupan", "port", port); os.Exit(1) slog.Error("port je zauzet ili nije dostupan", "port", port)
os.Exit(1)
} }
} }
@@ -419,4 +457,3 @@ func ocistiStareBackupe(folder string, max int) {
_ = os.Remove(f) _ = os.Remove(f)
} }
} }
+67
View File
@@ -0,0 +1,67 @@
package config
// Ovaj fajl izvodi iz profila firme (vidi Project.md §2, §4) koji su zakonski
// moduli uključeni za datu firmu. Profil se čuva kao key-value u tabeli
// `podesavanja`, pa helper prima već učitanu mapu (čista funkcija — lako se
// testira bez baze). Ovo je sloj IZNAD RBAC-a: „da li firma uopšte koristi
// modul", nezavisno od „da li korisnik sme".
// Ključevi profila firme u tabeli `podesavanja`.
const (
KljucPravniOblik = "firma_pravni_oblik"
KljucPdvObveznik = "firma_pdv_obveznik"
KljucFiskalizacija = "firma_fiskalizacija"
KljucRezim = "firma_rezim"
)
// Nazivi modula koje program pali/gasi prema profilu firme.
const (
ModulPdv = "pdv"
ModulFiskalizacija = "fiskalizacija"
ModulKpo = "kpo"
ModulDvojno = "dvojno"
)
// ModulUkljucen vraća da li je dati zakonski modul aktivan za firmu, na osnovu
// profila firme. Režim „samo evidencija" gasi ceo zakonski sloj — tada nijedan
// modul nije uključen, bez obzira na ostale prekidače.
func ModulUkljucen(podesavanja map[string]string, modul string) bool {
// podrazumevano (ključ još ne postoji) je „samo evidencija" — najbezbednije
// stanje: stara instalacija ne počinje da se ponaša kao poreski obveznik.
rezim := podesavanja[KljucRezim]
if rezim == "" {
rezim = "samo_evidencija"
}
if rezim != "pun" {
return false
}
switch modul {
case ModulFiskalizacija:
// fiskalizacija je nezavisna od pravnog oblika — zaseban prekidač
// „izdaje li račune građanima" (Project.md §3, napomena *).
return podesavanja[KljucFiskalizacija] == "da"
case ModulPdv:
// PDV evidencija se vodi kad je firma u sistemu PDV-a.
return podesavanja[KljucPdvObveznik] == "da"
case ModulKpo:
// KPO (knjiga o ostvarenom prometu) je samo za paušalce.
return podesavanja[KljucPravniOblik] == "pausalac"
case ModulDvojno:
// dvojno knjigovodstvo je za DOO (za preduzetnike je buduća podela, Project.md §8).
return podesavanja[KljucPravniOblik] == "doo"
default:
return false
}
}
// SviModuli vraća mapu svih poznatih modula → da li su uključeni za dati profil firme.
// Koristi se da šabloni uslovno prikazuju stavke menija (analogno mapi Dozvole).
func SviModuli(podesavanja map[string]string) map[string]bool {
moduli := []string{ModulPdv, ModulFiskalizacija, ModulKpo, ModulDvojno}
m := make(map[string]bool, len(moduli))
for _, modul := range moduli {
m[modul] = ModulUkljucen(podesavanja, modul)
}
return m
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"maps"
"testing"
)
func TestModulUkljucen(t *testing.T) {
// pun je profil punog režima sa svim prekidačima upaljenim i pravnim oblikom doo;
// pojedinačni testovi ga prepravljaju po potrebi preko pomoćne funkcije izmeni.
pun := map[string]string{
KljucRezim: "pun",
KljucPdvObveznik: "da",
KljucFiskalizacija: "da",
KljucPravniOblik: "doo",
}
// izmeni vraća kopiju mape sa promenjenim/dodatim ključem (da testovi ne dele stanje)
izmeni := func(osnova map[string]string, kljuc, vrednost string) map[string]string {
kopija := make(map[string]string, len(osnova)+1)
maps.Copy(kopija, osnova)
kopija[kljuc] = vrednost
return kopija
}
testovi := []struct {
naziv string
podesavanja map[string]string
modul string
ocekivano bool
}{
// master prekidač: režim „samo evidencija" gasi sve
{"prazna mapa — pdv ugašen (default samo_evidencija)", map[string]string{}, ModulPdv, false},
{"prazna mapa — fiskalizacija ugašena", map[string]string{}, ModulFiskalizacija, false},
{"prazna mapa — kpo ugašen", map[string]string{}, ModulKpo, false},
{"prazna mapa — dvojno ugašeno", map[string]string{}, ModulDvojno, false},
{"samo evidencija gasi pdv iako je obveznik", izmeni(pun, KljucRezim, "samo_evidencija"), ModulPdv, false},
{"samo evidencija gasi fiskalizaciju iako je upaljena", izmeni(pun, KljucRezim, "samo_evidencija"), ModulFiskalizacija, false},
// fiskalizacija — zaseban prekidač, nezavisan od oblika
{"pun + fiskalizacija da", pun, ModulFiskalizacija, true},
{"pun + fiskalizacija ne", izmeni(pun, KljucFiskalizacija, "ne"), ModulFiskalizacija, false},
// pdv — vezan isključivo za prekidač obveznika
{"pun + obveznik da", pun, ModulPdv, true},
{"pun + obveznik ne (čak i doo)", izmeni(pun, KljucPdvObveznik, "ne"), ModulPdv, false},
// kpo — samo paušalac
{"pun + paušalac → kpo", izmeni(pun, KljucPravniOblik, "pausalac"), ModulKpo, true},
{"pun + doo → nema kpo", pun, ModulKpo, false},
{"pun + preduzetnik → nema kpo", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulKpo, false},
// dvojno — samo doo
{"pun + doo → dvojno", pun, ModulDvojno, true},
{"pun + paušalac → nema dvojno", izmeni(pun, KljucPravniOblik, "pausalac"), ModulDvojno, false},
{"pun + preduzetnik → nema dvojno (buduća podela)", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulDvojno, false},
// nepoznat modul
{"nepoznat modul → false", pun, "nepostojeci", false},
}
for _, tt := range testovi {
t.Run(tt.naziv, func(t *testing.T) {
if got := ModulUkljucen(tt.podesavanja, tt.modul); got != tt.ocekivano {
t.Errorf("ModulUkljucen(%q) = %v, očekivano %v", tt.modul, got, tt.ocekivano)
}
})
}
}
func TestSviModuli(t *testing.T) {
// pun režim, doo, PDV obveznik, fiskalizacija → pdv, fiskalizacija, dvojno uključeni; kpo ne
pod := map[string]string{
KljucRezim: "pun",
KljucPdvObveznik: "da",
KljucFiskalizacija: "da",
KljucPravniOblik: "doo",
}
m := SviModuli(pod)
if len(m) != 4 {
t.Fatalf("SviModuli vraća %d modula, očekivano 4", len(m))
}
ocek := map[string]bool{ModulPdv: true, ModulFiskalizacija: true, ModulKpo: false, ModulDvojno: true}
for modul, want := range ocek {
if m[modul] != want {
t.Errorf("SviModuli[%q] = %v, očekivano %v", modul, m[modul], want)
}
}
// samo evidencija → svi ugašeni
prazna := SviModuli(map[string]string{KljucRezim: "samo_evidencija"})
for modul, ukljucen := range prazna {
if ukljucen {
t.Errorf("u režimu samo_evidencija modul %q ne sme biti uključen", modul)
}
}
}
+48 -1
View File
@@ -13,6 +13,8 @@ type ArtikalRepository interface {
DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error)
Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error)
Izmeni(ctx context.Context, a *model.Artikal) error Izmeni(ctx context.Context, a *model.Artikal) error
// AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci)
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
} }
@@ -20,7 +22,51 @@ type ArtikalRepository interface {
// KategorijaRepository definiše operacije nad kategorijama // KategorijaRepository definiše operacije nad kategorijama
type KategorijaRepository interface { type KategorijaRepository interface {
Lista(ctx context.Context) ([]model.Kategorija, error) Lista(ctx context.Context) ([]model.Kategorija, error)
DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error)
Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error)
Izmeni(ctx context.Context, k *model.Kategorija) error
}
// PdvStopaRepository definiše operacije nad šifarnikom PDV stopa
type PdvStopaRepository interface {
Lista(ctx context.Context, samoAktivne bool) ([]model.PdvStopa, error)
DohvatiID(ctx context.Context, id int64) (*model.PdvStopa, error)
Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error)
Izmeni(ctx context.Context, s *model.PdvStopa) error
PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error
}
// PdvKirRepository definiše operacije nad knjigom izdatih računa (KIR)
type PdvKirRepository interface {
Lista(ctx context.Context, od, do time.Time) ([]model.PdvKir, error)
DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error)
Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error)
Obrisi(ctx context.Context, id int64) error
// ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri stornu prodaje)
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error
}
// PdvKprRepository definiše operacije nad knjigom primljenih računa (KPR)
type PdvKprRepository interface {
Lista(ctx context.Context, od, do time.Time) ([]model.PdvKpr, error)
DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error)
Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error)
Obrisi(ctx context.Context, id int64) error
// ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri brisanju nabavke)
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error
}
// NivelacijaRepository definiše operacije nad evidencijom promene prodajnih cena
type NivelacijaRepository interface {
// PromeniCenu transakciono menja prodajnu cenu artikla i upisuje nivelacioni zapis;
// vraća kreirani zapis (sa starom i novom cenom). Izvor je "rucno".
PromeniCenu(ctx context.Context, artikalID int64, novaCena float64, razlog string, korisnikID *int64) (*model.Nivelacija, error)
// Kreiraj upisuje gotov nivelacioni zapis (npr. auto-trag pri izmeni artikla)
Kreiraj(ctx context.Context, n *model.Nivelacija) (int64, error)
// Lista vraća nivelacije u periodu (po datumu); nulti datum znači bez granice
Lista(ctx context.Context, od, do time.Time) ([]model.Nivelacija, error)
// ListaZaArtikal vraća sve nivelacije jednog artikla (najnovije prvo)
ListaZaArtikal(ctx context.Context, artikalID int64) ([]model.Nivelacija, error)
} }
// ArtikalFilter definiše parametre za filtriranje liste artikala // ArtikalFilter definiše parametre za filtriranje liste artikala
@@ -35,7 +81,8 @@ type NabavkaRepository interface {
Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error)
DohvatiID(ctx context.Context, id int64) (*model.Nabavka, error) DohvatiID(ctx context.Context, id int64) (*model.Nabavka, error)
DohvatiStavke(ctx context.Context, nabavkaID int64) ([]model.StavkaSaArtiklom, error) DohvatiStavke(ctx context.Context, nabavkaID int64) ([]model.StavkaSaArtiklom, error)
Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke) (int64, error) DohvatiTroskove(ctx context.Context, nabavkaID int64) ([]model.NabavkaTrosak, error)
Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak) (int64, error)
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
} }
+32 -11
View File
@@ -25,8 +25,8 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
SELECT SELECT
a.id, a.kategorija_id, a.naziv, a.opis, a.id, a.kategorija_id, a.naziv, a.opis,
a.kolicina, a.kolicina_min, a.lokacija, a.kolicina, a.kolicina_min, a.lokacija,
a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.napomena, a.datum_unosa, a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.marza, a.napomena, a.datum_unosa,
COALESCE(k.naziv, '') as kategorija_naziv 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
WHERE 1=1` WHERE 1=1`
@@ -59,12 +59,13 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
for redovi.Next() { for redovi.Next() {
var a model.ArtikalSaKategorijom var a model.ArtikalSaKategorijom
var kategorijaID sql.NullInt64 var kategorijaID sql.NullInt64
var marza, katMarza sql.NullFloat64
err := redovi.Scan( err := redovi.Scan(
&a.ID, &kategorijaID, &a.Naziv, &a.Opis, &a.ID, &kategorijaID, &a.Naziv, &a.Opis,
&a.Kolicina, &a.KolicinMin, &a.Lokacija, &a.Kolicina, &a.KolicinMin, &a.Lokacija,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa, &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa,
&a.KategorijaNaziv, &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)
@@ -73,6 +74,12 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
if kategorijaID.Valid { if kategorijaID.Valid {
a.KategorijaID = &kategorijaID.Int64 a.KategorijaID = &kategorijaID.Int64
} }
if marza.Valid {
a.Marza = &marza.Float64
}
if katMarza.Valid {
a.KategorijaMarza = &katMarza.Float64
}
a.KriticnaZaliha = a.Kolicina <= a.KolicinMin a.KriticnaZaliha = a.Kolicina <= a.KolicinMin
@@ -86,14 +93,15 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) { func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) {
var a model.Artikal var a model.Artikal
var kategorijaID sql.NullInt64 var kategorijaID sql.NullInt64
var marza sql.NullFloat64
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, kategorija_id, naziv, opis, kolicina, kolicina_min, SELECT id, kategorija_id, naziv, opis, kolicina, kolicina_min,
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, napomena, datum_unosa lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa
FROM artikli WHERE id = ?`, id).Scan( FROM artikli WHERE id = ?`, id).Scan(
&a.ID, &kategorijaID, &a.Naziv, &a.Opis, &a.ID, &kategorijaID, &a.Naziv, &a.Opis,
&a.Kolicina, &a.KolicinMin, &a.Lokacija, &a.Kolicina, &a.KolicinMin, &a.Lokacija,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa, &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
@@ -102,6 +110,9 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
if kategorijaID.Valid { if kategorijaID.Valid {
a.KategorijaID = &kategorijaID.Int64 a.KategorijaID = &kategorijaID.Int64
} }
if marza.Valid {
a.Marza = &marza.Float64
}
return &a, nil return &a, nil
} }
@@ -111,10 +122,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, naziv, opis, kolicina, kolicina_min, lokacija, (kategorija_id, naziv, opis, kolicina, kolicina_min, lokacija,
nabavna_cena, prodajna_cena, pdv_stopa, napomena) nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin, a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin,
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena, a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err) return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err)
@@ -134,11 +145,11 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
UPDATE artikli SET UPDATE artikli SET
kategorija_id = ?, naziv = ?, opis = ?, kolicina = ?, kategorija_id = ?, naziv = ?, opis = ?, kolicina = ?,
kolicina_min = ?, lokacija = ?, kolicina_min = ?, lokacija = ?,
nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, napomena = ? nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, marza = ?, napomena = ?
WHERE id = ?`, WHERE id = ?`,
a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KategorijaID, a.Naziv, a.Opis, a.Kolicina,
a.KolicinMin, a.Lokacija, a.KolicinMin, a.Lokacija,
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena, a.ID, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, a.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err) return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err)
@@ -147,6 +158,16 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
return nil return nil
} }
// AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe).
func (r *ArtikalRepo) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error {
_, err := r.db.ExecContext(ctx,
"UPDATE artikli SET nabavna_cena = ?, prodajna_cena = ? WHERE id = ?", nabavna, prodajna, id)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.AzurirajCene: %w", err)
}
return nil
}
// PremestiKategoriju menja samo kategoriju artikla (premeštanje u drugu kategoriju). // PremestiKategoriju menja samo kategoriju artikla (premeštanje u drugu kategoriju).
// kategorijaID može biti nil — tada artikal ostaje bez kategorije. // kategorijaID može biti nil — tada artikal ostaje bez kategorije.
func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error { func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error {
+15 -12
View File
@@ -21,7 +21,7 @@ func NoviDobavljacRepo(db *sql.DB) *DobavljacRepo {
// Lista vraća listu dobavljača sa opcionom pretragom po nazivu // Lista vraća listu dobavljača sa opcionom pretragom po nazivu
func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dobavljac, error) { func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dobavljac, error) {
upit := ` upit := `
SELECT id, naziv, kontakt_osoba, telefon, email, napomena, datum_unosa SELECT id, naziv, kontakt_osoba, telefon, email, pib, mesto, napomena, datum_unosa
FROM dobavljaci FROM dobavljaci
WHERE 1=1` WHERE 1=1`
@@ -43,9 +43,9 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob
var rezultat []model.Dobavljac var rezultat []model.Dobavljac
for redovi.Next() { for redovi.Next() {
var d model.Dobavljac var d model.Dobavljac
var kontaktOsoba, telefon, email, napomena sql.NullString var kontaktOsoba, telefon, email, pib, mesto, napomena sql.NullString
err := redovi.Scan( err := redovi.Scan(
&d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &napomena, &d.DatumUnosa, &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &pib, &mesto, &napomena, &d.DatumUnosa,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: DobavljacRepo.Lista: scan: %w", err) return nil, fmt.Errorf("ntech: DobavljacRepo.Lista: scan: %w", err)
@@ -53,6 +53,8 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob
d.KontaktOsoba = kontaktOsoba.String d.KontaktOsoba = kontaktOsoba.String
d.Telefon = telefon.String d.Telefon = telefon.String
d.Email = email.String d.Email = email.String
d.PIB = pib.String
d.Mesto = mesto.String
d.Napomena = napomena.String d.Napomena = napomena.String
rezultat = append(rezultat, d) rezultat = append(rezultat, d)
} }
@@ -63,12 +65,12 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob
// DohvatiID vraća jednog dobavljača po ID-u // DohvatiID vraća jednog dobavljača po ID-u
func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavljac, error) { func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavljac, error) {
var d model.Dobavljac var d model.Dobavljac
var kontaktOsoba, telefon, email, napomena sql.NullString var kontaktOsoba, telefon, email, pib, mesto, napomena sql.NullString
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, naziv, kontakt_osoba, telefon, email, napomena, datum_unosa SELECT id, naziv, kontakt_osoba, telefon, email, pib, mesto, napomena, datum_unosa
FROM dobavljaci WHERE id = ?`, id).Scan( FROM dobavljaci WHERE id = ?`, id).Scan(
&d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &napomena, &d.DatumUnosa, &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &pib, &mesto, &napomena, &d.DatumUnosa,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: DobavljacRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: DobavljacRepo.DohvatiID: %w", err)
@@ -77,6 +79,8 @@ func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavlj
d.KontaktOsoba = kontaktOsoba.String d.KontaktOsoba = kontaktOsoba.String
d.Telefon = telefon.String d.Telefon = telefon.String
d.Email = email.String d.Email = email.String
d.PIB = pib.String
d.Mesto = mesto.String
d.Napomena = napomena.String d.Napomena = napomena.String
return &d, nil return &d, nil
@@ -85,10 +89,10 @@ func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavlj
// Kreiraj dodaje novog dobavljača u bazu // Kreiraj dodaje novog dobavljača u bazu
func (r *DobavljacRepo) Kreiraj(ctx context.Context, d *model.Dobavljac) (int64, error) { func (r *DobavljacRepo) Kreiraj(ctx context.Context, d *model.Dobavljac) (int64, error) {
rezultat, err := r.db.ExecContext(ctx, ` rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO dobavljaci (naziv, kontakt_osoba, telefon, email, napomena) INSERT INTO dobavljaci (naziv, kontakt_osoba, telefon, email, pib, mesto, napomena)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
d.Naziv, nullString(d.KontaktOsoba), nullString(d.Telefon), d.Naziv, nullString(d.KontaktOsoba), nullString(d.Telefon),
nullString(d.Email), nullString(d.Napomena), nullString(d.Email), nullString(d.PIB), nullString(d.Mesto), nullString(d.Napomena),
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: DobavljacRepo.Kreiraj: %w", err) return 0, fmt.Errorf("ntech: DobavljacRepo.Kreiraj: %w", err)
@@ -106,10 +110,10 @@ func (r *DobavljacRepo) Kreiraj(ctx context.Context, d *model.Dobavljac) (int64,
func (r *DobavljacRepo) Izmeni(ctx context.Context, d *model.Dobavljac) error { func (r *DobavljacRepo) Izmeni(ctx context.Context, d *model.Dobavljac) error {
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
UPDATE dobavljaci SET UPDATE dobavljaci SET
naziv = ?, kontakt_osoba = ?, telefon = ?, email = ?, napomena = ? naziv = ?, kontakt_osoba = ?, telefon = ?, email = ?, pib = ?, mesto = ?, napomena = ?
WHERE id = ?`, WHERE id = ?`,
d.Naziv, nullString(d.KontaktOsoba), nullString(d.Telefon), d.Naziv, nullString(d.KontaktOsoba), nullString(d.Telefon),
nullString(d.Email), nullString(d.Napomena), d.ID, nullString(d.Email), nullString(d.PIB), nullString(d.Mesto), nullString(d.Napomena), d.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("ntech: DobavljacRepo.Izmeni: %w", err) return fmt.Errorf("ntech: DobavljacRepo.Izmeni: %w", err)
@@ -127,4 +131,3 @@ func (r *DobavljacRepo) Obrisi(ctx context.Context, id int64) error {
return nil return nil
} }
+40 -4
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 FROM kategorije ORDER BY naziv ASC") redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis, 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)
} }
@@ -30,12 +30,16 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
for redovi.Next() { for redovi.Next() {
var k model.Kategorija var k model.Kategorija
var opis sql.NullString var opis sql.NullString
if err := redovi.Scan(&k.ID, &k.Naziv, &opis); err != nil { var marza sql.NullFloat64
if err := redovi.Scan(&k.ID, &k.Naziv, &opis, &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 marza.Valid {
k.Marza = &marza.Float64
}
rezultat = append(rezultat, k) rezultat = append(rezultat, k)
} }
@@ -45,8 +49,8 @@ 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) {
rezultat, err := r.db.ExecContext(ctx, rezultat, err := r.db.ExecContext(ctx,
"INSERT INTO kategorije (naziv, opis) VALUES (?, ?)", "INSERT INTO kategorije (naziv, opis, marza) VALUES (?, ?, ?)",
k.Naziv, k.Opis, k.Naziv, k.Opis, 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)
@@ -59,3 +63,35 @@ func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int6
return id, nil return id, nil
} }
// 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 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)
if err != nil {
return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err)
}
if opis.Valid {
k.Opis = opis.String
}
if marza.Valid {
k.Marza = &marza.Float64
}
return &k, nil
}
// Izmeni ažurira naziv, opis i maržu postojeće kategorije
func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error {
_, err := r.db.ExecContext(ctx,
"UPDATE kategorije SET naziv = ?, opis = ?, marza = ? WHERE id = ?",
k.Naziv, k.Opis, k.Marza, k.ID,
)
if err != nil {
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
}
return nil
}
+13 -11
View File
@@ -21,7 +21,7 @@ func NoviKlijentRepo(db *sql.DB) *KlijentRepo {
// Lista vraća listu klijenata sa opcionom pretragom po imenu, prezimenu ili nazivu firme // Lista vraća listu klijenata sa opcionom pretragom po imenu, prezimenu ili nazivu firme
func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klijent, error) { func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klijent, error) {
upit := ` upit := `
SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti FROM klijenti
WHERE 1=1` WHERE 1=1`
@@ -44,9 +44,9 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
var rezultat []model.Klijent var rezultat []model.Klijent
for redovi.Next() { for redovi.Next() {
var k model.Klijent var k model.Klijent
var ime, prezime, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString
err := redovi.Scan( err := redovi.Scan(
&k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa, &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: KlijentRepo.Lista: scan: %w", err) return nil, fmt.Errorf("ntech: KlijentRepo.Lista: scan: %w", err)
@@ -58,6 +58,7 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
k.PIB = pib.String k.PIB = pib.String
k.Telefon = telefon.String k.Telefon = telefon.String
k.Email = email.String k.Email = email.String
k.Mesto = mesto.String
k.Napomena = napomena.String k.Napomena = napomena.String
rezultat = append(rezultat, k) rezultat = append(rezultat, k)
} }
@@ -68,12 +69,12 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
// DohvatiID vraća jednog klijenta po ID-u // DohvatiID vraća jednog klijenta po ID-u
func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) { func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) {
var k model.Klijent var k model.Klijent
var ime, prezime, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE id = ?`, id).Scan( FROM klijenti WHERE id = ?`, id).Scan(
&k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa, &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: KlijentRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: KlijentRepo.DohvatiID: %w", err)
@@ -86,6 +87,7 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent,
k.PIB = pib.String k.PIB = pib.String
k.Telefon = telefon.String k.Telefon = telefon.String
k.Email = email.String k.Email = email.String
k.Mesto = mesto.String
k.Napomena = napomena.String k.Napomena = napomena.String
return &k, nil return &k, nil
@@ -97,11 +99,11 @@ func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, err
k.Tip = "fizicko" k.Tip = "fizicko"
} }
rezultat, err := r.db.ExecContext(ctx, ` rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena) INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG), k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG),
nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon), nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon),
nullString(k.Email), nullString(k.Napomena), nullString(k.Email), nullString(k.Mesto), nullString(k.Napomena),
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: KlijentRepo.Kreiraj: %w", err) return 0, fmt.Errorf("ntech: KlijentRepo.Kreiraj: %w", err)
@@ -123,11 +125,11 @@ func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error {
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
UPDATE klijenti SET UPDATE klijenti SET
tip = ?, ime = ?, prezime = ?, jmbg = ?, naziv_firme = ?, tip = ?, ime = ?, prezime = ?, jmbg = ?, naziv_firme = ?,
pib = ?, telefon = ?, email = ?, napomena = ? pib = ?, telefon = ?, email = ?, mesto = ?, napomena = ?
WHERE id = ?`, WHERE id = ?`,
k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG), k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG),
nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon), nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon),
nullString(k.Email), nullString(k.Napomena), k.ID, nullString(k.Email), nullString(k.Mesto), nullString(k.Napomena), k.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("ntech: KlijentRepo.Izmeni: %w", err) return fmt.Errorf("ntech: KlijentRepo.Izmeni: %w", err)
+50 -11
View File
@@ -22,7 +22,7 @@ func NoviNabavkaRepo(db *sql.DB) *NabavkaRepo {
func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error) { func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error) {
redovi, err := r.db.QueryContext(ctx, ` redovi, err := r.db.QueryContext(ctx, `
SELECT SELECT
n.id, n.dobavljac_id, n.napomena, n.ukupno, n.datum, n.id, n.dobavljac_id, n.napomena, n.ukupno, n.metod_raspodele, n.datum,
COALESCE(d.naziv, '') AS dobavljac_naziv COALESCE(d.naziv, '') AS dobavljac_naziv
FROM nabavke n FROM nabavke n
LEFT JOIN dobavljaci d ON n.dobavljac_id = d.id LEFT JOIN dobavljaci d ON n.dobavljac_id = d.id
@@ -36,10 +36,10 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err
for redovi.Next() { for redovi.Next() {
var n model.NabavkaSaDetaljem var n model.NabavkaSaDetaljem
var dobavljacID sql.NullInt64 var dobavljacID sql.NullInt64
var napomena sql.NullString var napomena, metod sql.NullString
err := redovi.Scan( err := redovi.Scan(
&n.ID, &dobavljacID, &napomena, &n.Ukupno, &n.Datum, &n.ID, &dobavljacID, &napomena, &n.Ukupno, &metod, &n.Datum,
&n.DobavljacNaziv, &n.DobavljacNaziv,
) )
if err != nil { if err != nil {
@@ -50,6 +50,7 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err
n.DobavljacID = &dobavljacID.Int64 n.DobavljacID = &dobavljacID.Int64
} }
n.Napomena = napomena.String n.Napomena = napomena.String
n.MetodRaspodele = metod.String
rezultat = append(rezultat, n) rezultat = append(rezultat, n)
} }
@@ -61,12 +62,12 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err
func (r *NabavkaRepo) DohvatiID(ctx context.Context, id int64) (*model.Nabavka, error) { func (r *NabavkaRepo) DohvatiID(ctx context.Context, id int64) (*model.Nabavka, error) {
var n model.Nabavka var n model.Nabavka
var dobavljacID sql.NullInt64 var dobavljacID sql.NullInt64
var napomena sql.NullString var napomena, metod sql.NullString
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, dobavljac_id, napomena, ukupno, datum SELECT id, dobavljac_id, napomena, ukupno, metod_raspodele, datum
FROM nabavke WHERE id = ?`, id).Scan( FROM nabavke WHERE id = ?`, id).Scan(
&n.ID, &dobavljacID, &napomena, &n.Ukupno, &n.Datum, &n.ID, &dobavljacID, &napomena, &n.Ukupno, &metod, &n.Datum,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: NabavkaRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: NabavkaRepo.DohvatiID: %w", err)
@@ -76,6 +77,7 @@ func (r *NabavkaRepo) DohvatiID(ctx context.Context, id int64) (*model.Nabavka,
n.DobavljacID = &dobavljacID.Int64 n.DobavljacID = &dobavljacID.Int64
} }
n.Napomena = napomena.String n.Napomena = napomena.String
n.MetodRaspodele = metod.String
return &n, nil return &n, nil
} }
@@ -113,8 +115,33 @@ func (r *NabavkaRepo) DohvatiStavke(ctx context.Context, nabavkaID int64) ([]mod
return rezultat, nil return rezultat, nil
} }
// Kreiraj upisuje novu nabavku sa svim stavkama u jednoj transakciji i ažurira stanje magacina // DohvatiTroskove vraća sve zavisne troškove jedne nabavke
func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke) (int64, error) { func (r *NabavkaRepo) DohvatiTroskove(ctx context.Context, nabavkaID int64) ([]model.NabavkaTrosak, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT id, nabavka_id, naziv, iznos
FROM nabavka_troskovi
WHERE nabavka_id = ?
ORDER BY id ASC`, nabavkaID)
if err != nil {
return nil, fmt.Errorf("ntech: NabavkaRepo.DohvatiTroskove: %w", err)
}
defer redovi.Close()
var rezultat []model.NabavkaTrosak
for redovi.Next() {
var t model.NabavkaTrosak
if err := redovi.Scan(&t.ID, &t.NabavkaID, &t.Naziv, &t.Iznos); err != nil {
return nil, fmt.Errorf("ntech: NabavkaRepo.DohvatiTroskove: scan: %w", err)
}
rezultat = append(rezultat, t)
}
return rezultat, nil
}
// Kreiraj upisuje novu nabavku sa svim stavkama i zavisnim troškovima u jednoj
// transakciji i ažurira stanje magacina
func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak) (int64, error) {
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: begin: %w", err) return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: begin: %w", err)
@@ -130,9 +157,9 @@ func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []mo
// upisujemo zaglavlje nabavke // upisujemo zaglavlje nabavke
rezultat, err := tx.ExecContext(ctx, ` rezultat, err := tx.ExecContext(ctx, `
INSERT INTO nabavke (dobavljac_id, napomena, ukupno) INSERT INTO nabavke (dobavljac_id, napomena, ukupno, metod_raspodele)
VALUES (?, ?, ?)`, VALUES (?, ?, ?, ?)`,
nullInt64(n.DobavljacID), nullString(n.Napomena), ukupno, nullInt64(n.DobavljacID), nullString(n.Napomena), ukupno, nullString(n.MetodRaspodele),
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: insert nabavka: %w", err) return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: insert nabavka: %w", err)
@@ -143,6 +170,18 @@ func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []mo
return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: last insert id: %w", err) return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: last insert id: %w", err)
} }
// upisujemo zavisne troškove (ako ih ima)
for _, t := range troskovi {
_, err := tx.ExecContext(ctx, `
INSERT INTO nabavka_troskovi (nabavka_id, naziv, iznos)
VALUES (?, ?, ?)`,
nabavkaID, t.Naziv, t.Iznos,
)
if err != nil {
return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: insert trošak: %w", err)
}
}
// upisujemo svaku stavku i ažuriramo stanje artikla u magacinu // upisujemo svaku stavku i ažuriramo stanje artikla u magacinu
for _, s := range stavke { for _, s := range stavke {
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
+150
View File
@@ -0,0 +1,150 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"ntech/internal/model"
)
// NivelacijaRepo je SQLite implementacija NivelacijaRepository interfejsa
type NivelacijaRepo struct {
db *sql.DB
}
// NoviNivelacijaRepo kreira novi NivelacijaRepo
func NoviNivelacijaRepo(db *sql.DB) *NivelacijaRepo {
return &NivelacijaRepo{db: db}
}
// ErrArtikalNePostoji se vraća kada se menja cena nepostojećeg artikla
var ErrArtikalNePostoji = errors.New("artikal ne postoji")
// PromeniCenu transakciono menja prodajnu cenu artikla i upisuje nivelacioni zapis.
// Stara cena se čita iz baze unutar transakcije; izvor je "rucno".
func (r *NivelacijaRepo) PromeniCenu(ctx context.Context, artikalID int64, novaCena float64, razlog string, korisnikID *int64) (*model.Nivelacija, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: begin: %w", err)
}
defer tx.Rollback()
var stara float64
err = tx.QueryRowContext(ctx, "SELECT prodajna_cena FROM artikli WHERE id = ?", artikalID).Scan(&stara)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrArtikalNePostoji
}
if err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: čitanje cene: %w", err)
}
if _, err = tx.ExecContext(ctx, "UPDATE artikli SET prodajna_cena = ? WHERE id = ?", novaCena, artikalID); err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: update cene: %w", err)
}
sada := time.Now()
rez, err := tx.ExecContext(ctx, `
INSERT INTO nivelacije (artikal_id, stara_cena, nova_cena, razlog, izvor, korisnik_id, datum)
VALUES (?, ?, ?, ?, 'rucno', ?, ?)`,
artikalID, stara, novaCena, razlog, izvorIDArg(korisnikID), sada)
if err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: upis nivelacije: %w", err)
}
id, _ := rez.LastInsertId()
if err = tx.Commit(); err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: commit: %w", err)
}
return &model.Nivelacija{
ID: id, ArtikalID: artikalID, StaraCena: stara, NovaCena: novaCena,
Razlog: razlog, Izvor: "rucno", KorisnikID: korisnikID, Datum: sada,
}, nil
}
// Kreiraj upisuje gotov nivelacioni zapis (npr. auto-trag pri izmeni artikla).
func (r *NivelacijaRepo) Kreiraj(ctx context.Context, n *model.Nivelacija) (int64, error) {
izvor := n.Izvor
if izvor == "" {
izvor = "rucno"
}
datum := n.Datum
if datum.IsZero() {
datum = time.Now()
}
rez, err := r.db.ExecContext(ctx, `
INSERT INTO nivelacije (artikal_id, stara_cena, nova_cena, razlog, izvor, korisnik_id, datum)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
n.ArtikalID, n.StaraCena, n.NovaCena, n.Razlog, izvor, izvorIDArg(n.KorisnikID), datum)
if err != nil {
return 0, fmt.Errorf("ntech: NivelacijaRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: NivelacijaRepo.Kreiraj: id: %w", err)
}
return id, nil
}
// Lista vraća nivelacije u periodu (po datumu); nulti datum znači bez granice.
func (r *NivelacijaRepo) Lista(ctx context.Context, od, do time.Time) ([]model.Nivelacija, error) {
upit := nivelacijaSelect + " WHERE 1=1"
args := []any{}
if !od.IsZero() {
upit += " AND n.datum >= ?"
args = append(args, od)
}
if !do.IsZero() {
upit += " AND n.datum <= ?"
args = append(args, do)
}
upit += " ORDER BY n.datum DESC, n.id DESC"
return r.upitVisestruko(ctx, upit, args...)
}
// ListaZaArtikal vraća sve nivelacije jednog artikla (najnovije prvo).
func (r *NivelacijaRepo) ListaZaArtikal(ctx context.Context, artikalID int64) ([]model.Nivelacija, error) {
upit := nivelacijaSelect + " WHERE n.artikal_id = ? ORDER BY n.datum DESC, n.id DESC"
return r.upitVisestruko(ctx, upit, artikalID)
}
// nivelacijaSelect je zajednički SELECT sa JOIN-ovima na artikle i korisnike (za prikaz).
const nivelacijaSelect = `
SELECT n.id, n.artikal_id, COALESCE(a.naziv, ''), n.stara_cena, n.nova_cena,
COALESCE(n.razlog, ''), n.izvor, n.korisnik_id, COALESCE(k.korisnicko_ime, ''),
n.datum, n.datum_unosa
FROM nivelacije n
LEFT JOIN artikli a ON a.id = n.artikal_id
LEFT JOIN korisnici k ON k.id = n.korisnik_id`
// upitVisestruko izvršava SELECT i skenira sve redove u listu nivelacija.
func (r *NivelacijaRepo) upitVisestruko(ctx context.Context, upit string, args ...any) ([]model.Nivelacija, error) {
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo: %w", err)
}
defer redovi.Close()
var rezultat []model.Nivelacija
for redovi.Next() {
var n model.Nivelacija
var korisnikID sql.NullInt64
if err := redovi.Scan(
&n.ID, &n.ArtikalID, &n.ArtikalNaziv, &n.StaraCena, &n.NovaCena,
&n.Razlog, &n.Izvor, &korisnikID, &n.KorisnikIme, &n.Datum, &n.DatumUnosa,
); err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo: scan: %w", err)
}
if korisnikID.Valid {
n.KorisnikID = &korisnikID.Int64
}
rezultat = append(rezultat, n)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo: %w", err)
}
return rezultat, nil
}
+294
View File
@@ -0,0 +1,294 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"ntech/internal/model"
)
// --- KIR: knjiga izdatih računa ---
// PdvKirRepo je SQLite implementacija PdvKirRepository interfejsa
type PdvKirRepo struct {
db *sql.DB
}
// NoviPdvKirRepo kreira novi PdvKirRepo
func NoviPdvKirRepo(db *sql.DB) *PdvKirRepo {
return &PdvKirRepo{db: db}
}
// Lista vraća zapise KIR-a u zadatom periodu (po datumu prometa); nulti datum znači bez granice
func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKir, error) {
upit := `
SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta,
kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno,
COALESCE(napomena, ''), izvor, izvor_id, datum_unosa
FROM pdv_kir WHERE 1=1`
args := []any{}
if !od.IsZero() {
upit += " AND datum_prometa >= ?"
args = append(args, od)
}
if !do.IsZero() {
upit += " AND datum_prometa <= ?"
args = append(args, do)
}
upit += " ORDER BY datum_prometa ASC, id ASC"
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.PdvKir
for redovi.Next() {
k, err := skenirajKir(redovi.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: scan: %w", err)
}
rezultat = append(rezultat, k)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: %w", err)
}
return rezultat, nil
}
// DohvatiID vraća jedan zapis KIR-a po identifikatoru
func (r *PdvKirRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error) {
red := r.db.QueryRowContext(ctx, `
SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta,
kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno,
COALESCE(napomena, ''), izvor, izvor_id, datum_unosa
FROM pdv_kir WHERE id = ?`, id)
k, err := skenirajKir(red.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiID: %w", err)
}
return &k, nil
}
// skenirajKir čita jedan red KIR-a; izvor_id je nullable pa ide preko sql.NullInt64
func skenirajKir(scan func(...any) error) (model.PdvKir, error) {
var k model.PdvKir
var izvorID sql.NullInt64
if err := scan(
&k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &k.BrojDokumenta,
&k.KupacNaziv, &k.KupacPib, &k.KupacMesto,
&k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna,
&k.OslobodenSaPravom, &k.OslobodenBezPrava, &k.Ukupno,
&k.Napomena, &k.Izvor, &izvorID, &k.DatumUnosa,
); err != nil {
return model.PdvKir{}, err
}
if izvorID.Valid {
k.IzvorID = &izvorID.Int64
}
return k, nil
}
// Kreiraj dodaje novi zapis u KIR i vraća njegov id
func (r *PdvKirRepo) Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error) {
rez, err := r.db.ExecContext(ctx, `
INSERT INTO pdv_kir (
datum_prometa, datum_knjizenja, broj_dokumenta,
kupac_naziv, kupac_pib, kupac_mesto,
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena, izvor, izvor_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.DatumPrometa, k.DatumKnjizenja, k.BrojDokumenta,
k.KupacNaziv, k.KupacPib, k.KupacMesto,
k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna,
k.OslobodenSaPravom, k.OslobodenBezPrava, k.Ukupno, k.Napomena,
izvorIliRucno(k.Izvor), izvorIDArg(k.IzvorID))
if err != nil {
return 0, fmt.Errorf("ntech: PdvKirRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: PdvKirRepo.Kreiraj: id: %w", err)
}
return id, nil
}
// Obrisi briše zapis KIR-a
func (r *PdvKirRepo) Obrisi(ctx context.Context, id int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE id = ?", id); err != nil {
return fmt.Errorf("ntech: PdvKirRepo.Obrisi: %w", err)
}
return nil
}
// ObrisiPoIzvoru briše KIR zapise vezane za dati izvor (npr. pri stornu/brisanju prodaje)
func (r *PdvKirRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
return fmt.Errorf("ntech: PdvKirRepo.ObrisiPoIzvoru: %w", err)
}
return nil
}
// izvorIliRucno vraća izvor ili podrazumevano „rucno" (izvor kolona je NOT NULL)
func izvorIliRucno(izvor string) string {
if izvor == "" {
return "rucno"
}
return izvor
}
// izvorIDArg pretvara *int64 u argument za upit (nil → NULL)
func izvorIDArg(id *int64) any {
if id == nil {
return nil
}
return *id
}
// --- KPR: knjiga primljenih računa ---
// PdvKprRepo je SQLite implementacija PdvKprRepository interfejsa
type PdvKprRepo struct {
db *sql.DB
}
// NoviPdvKprRepo kreira novi PdvKprRepo
func NoviPdvKprRepo(db *sql.DB) *PdvKprRepo {
return &PdvKprRepo{db: db}
}
// Lista vraća zapise KPR-a u zadatom periodu (po datumu prometa); nulti datum znači bez granice
func (r *PdvKprRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKpr, error) {
upit := `
SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno,
COALESCE(napomena, ''), izvor, izvor_id, uvoz, datum_unosa
FROM pdv_kpr WHERE 1=1`
args := []any{}
if !od.IsZero() {
upit += " AND datum_prometa >= ?"
args = append(args, od)
}
if !do.IsZero() {
upit += " AND datum_prometa <= ?"
args = append(args, do)
}
upit += " ORDER BY datum_prometa ASC, id ASC"
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.PdvKpr
for redovi.Next() {
k, err := skenirajKpr(redovi.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: scan: %w", err)
}
rezultat = append(rezultat, k)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: %w", err)
}
return rezultat, nil
}
// DohvatiID vraća jedan zapis KPR-a po identifikatoru
func (r *PdvKprRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error) {
red := r.db.QueryRowContext(ctx, `
SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno,
COALESCE(napomena, ''), izvor, izvor_id, uvoz, datum_unosa
FROM pdv_kpr WHERE id = ?`, id)
k, err := skenirajKpr(red.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiID: %w", err)
}
return &k, nil
}
// skenirajKpr čita jedan red KPR-a; datum_placanja je nullable pa ide preko sql.NullTime
func skenirajKpr(scan func(...any) error) (model.PdvKpr, error) {
var k model.PdvKpr
var datumPlacanja sql.NullTime
var izvorID sql.NullInt64
var uvoz int64
if err := scan(
&k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &datumPlacanja, &k.BrojDokumenta,
&k.DobavljacNaziv, &k.DobavljacPib, &k.DobavljacMesto,
&k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna,
&k.PdvBezOdbitka, &k.OslobodenNabavka, &k.Ukupno,
&k.Napomena, &k.Izvor, &izvorID, &uvoz, &k.DatumUnosa,
); err != nil {
return model.PdvKpr{}, err
}
if datumPlacanja.Valid {
k.DatumPlacanja = &datumPlacanja.Time
}
if izvorID.Valid {
k.IzvorID = &izvorID.Int64
}
k.Uvoz = uvoz != 0
return k, nil
}
// Kreiraj dodaje novi zapis u KPR i vraća njegov id
func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) {
var datumPlacanja any
if k.DatumPlacanja != nil {
datumPlacanja = *k.DatumPlacanja
}
uvoz := 0
if k.Uvoz {
uvoz = 1
}
rez, err := r.db.ExecContext(ctx, `
INSERT INTO pdv_kpr (
datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
dobavljac_naziv, dobavljac_pib, dobavljac_mesto,
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id, uvoz
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.DatumPrometa, k.DatumKnjizenja, datumPlacanja, k.BrojDokumenta,
k.DobavljacNaziv, k.DobavljacPib, k.DobavljacMesto,
k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna,
k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena,
izvorIliRucno(k.Izvor), izvorIDArg(k.IzvorID), uvoz)
if err != nil {
return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: id: %w", err)
}
return id, nil
}
// Obrisi briše zapis KPR-a
func (r *PdvKprRepo) Obrisi(ctx context.Context, id int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE id = ?", id); err != nil {
return fmt.Errorf("ntech: PdvKprRepo.Obrisi: %w", err)
}
return nil
}
// ObrisiPoIzvoru briše KPR zapise vezane za dati izvor (npr. pri brisanju nabavke)
func (r *PdvKprRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
return fmt.Errorf("ntech: PdvKprRepo.ObrisiPoIzvoru: %w", err)
}
return nil
}
+173
View File
@@ -0,0 +1,173 @@
package sqlite
import (
"context"
"testing"
"time"
"ntech/internal/model"
)
// istiDan poredi datume po godini/mesecu/danu (izbegava razlike u vremenskoj zoni/času)
func istiDan(a, b time.Time) bool {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
func TestPdvKirRepo(t *testing.T) {
db := testDB(t)
repo := NoviPdvKirRepo(db)
ctx := context.Background()
dp := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
dk := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
id, err := repo.Kreiraj(ctx, &model.PdvKir{
DatumPrometa: dp, DatumKnjizenja: dk, BrojDokumenta: "R-1",
KupacNaziv: "Kupac doo", KupacPib: "123456789", KupacMesto: "Niš",
OsnovicaOpsta: 100, PdvOpsta: 20, Ukupno: 120,
})
if err != nil {
t.Fatalf("Kreiraj: %v", err)
}
t.Run("datum i iznosi round-trip", func(t *testing.T) {
k, err := repo.DohvatiID(ctx, id)
if err != nil {
t.Fatalf("DohvatiID: %v", err)
}
if !istiDan(k.DatumPrometa, dp) {
t.Errorf("datum_prometa = %v, očekivano %v", k.DatumPrometa, dp)
}
if !istiDan(k.DatumKnjizenja, dk) {
t.Errorf("datum_knjizenja = %v, očekivano %v", k.DatumKnjizenja, dk)
}
if k.KupacNaziv != "Kupac doo" || k.KupacPib != "123456789" || k.KupacMesto != "Niš" {
t.Errorf("kupac podaci ne odgovaraju: %+v", k)
}
if k.OsnovicaOpsta != 100 || k.PdvOpsta != 20 || k.Ukupno != 120 {
t.Errorf("iznosi ne odgovaraju: %+v", k)
}
})
t.Run("filter perioda", func(t *testing.T) {
uPeriodu, err := repo.Lista(ctx, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Lista u periodu: %v", err)
}
if len(uPeriodu) != 1 {
t.Errorf("u junu očekivano 1 zapis, dobijeno %d", len(uPeriodu))
}
vanPerioda, err := repo.Lista(ctx, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Lista van perioda: %v", err)
}
if len(vanPerioda) != 0 {
t.Errorf("u julu očekivano 0 zapisa, dobijeno %d", len(vanPerioda))
}
})
t.Run("brisanje", func(t *testing.T) {
if err := repo.Obrisi(ctx, id); err != nil {
t.Fatalf("Obrisi: %v", err)
}
sve, _ := repo.Lista(ctx, time.Time{}, time.Time{})
if len(sve) != 0 {
t.Errorf("posle brisanja očekivano 0, dobijeno %d", len(sve))
}
})
}
func TestPdvKprRepo(t *testing.T) {
db := testDB(t)
repo := NoviPdvKprRepo(db)
ctx := context.Background()
dp := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
dpl := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
t.Run("sa datumom plaćanja", func(t *testing.T) {
id, err := repo.Kreiraj(ctx, &model.PdvKpr{
DatumPrometa: dp, DatumKnjizenja: dp, DatumPlacanja: &dpl, BrojDokumenta: "U-1",
DobavljacNaziv: "Dobavljač doo", OsnovicaOpsta: 200, PdvOpsta: 40, Ukupno: 240,
})
if err != nil {
t.Fatalf("Kreiraj: %v", err)
}
k, err := repo.DohvatiID(ctx, id)
if err != nil {
t.Fatalf("DohvatiID: %v", err)
}
if k.DatumPlacanja == nil {
t.Fatalf("datum_placanja je nil, očekivan %v", dpl)
}
if !istiDan(*k.DatumPlacanja, dpl) {
t.Errorf("datum_placanja = %v, očekivano %v", *k.DatumPlacanja, dpl)
}
if k.OsnovicaOpsta != 200 || k.PdvOpsta != 40 {
t.Errorf("iznosi ne odgovaraju: %+v", k)
}
})
t.Run("bez datuma plaćanja (NULL)", func(t *testing.T) {
id, err := repo.Kreiraj(ctx, &model.PdvKpr{
DatumPrometa: dp, DatumKnjizenja: dp, DatumPlacanja: nil, BrojDokumenta: "U-2",
DobavljacNaziv: "Dobavljač 2", OsnovicaPosebna: 50, PdvPosebna: 5, Ukupno: 55,
})
if err != nil {
t.Fatalf("Kreiraj: %v", err)
}
k, err := repo.DohvatiID(ctx, id)
if err != nil {
t.Fatalf("DohvatiID: %v", err)
}
if k.DatumPlacanja != nil {
t.Errorf("datum_placanja = %v, očekivan nil", *k.DatumPlacanja)
}
})
}
func TestPdvKirIzvor(t *testing.T) {
db := testDB(t)
repo := NoviPdvKirRepo(db)
ctx := context.Background()
dp := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
izvorID := int64(7)
// ručni zapis (bez izvora) → default „rucno", izvor_id NULL
if _, err := repo.Kreiraj(ctx, &model.PdvKir{
DatumPrometa: dp, DatumKnjizenja: dp, BrojDokumenta: "R-1", KupacNaziv: "Ručni",
}); err != nil {
t.Fatalf("Kreiraj ručni: %v", err)
}
// auto zapis iz prodaje
if _, err := repo.Kreiraj(ctx, &model.PdvKir{
DatumPrometa: dp, DatumKnjizenja: dp, BrojDokumenta: "P-1", KupacNaziv: "Iz prodaje",
Izvor: "prodaja", IzvorID: &izvorID,
}); err != nil {
t.Fatalf("Kreiraj auto: %v", err)
}
sve, _ := repo.Lista(ctx, time.Time{}, time.Time{})
if len(sve) != 2 {
t.Fatalf("očekivano 2 zapisa, dobijeno %d", len(sve))
}
for _, z := range sve {
if z.BrojDokumenta == "R-1" && (z.Izvor != "rucno" || z.IzvorID != nil) {
t.Errorf("ručni zapis: izvor=%q izvor_id=%v, očekivano rucno/nil", z.Izvor, z.IzvorID)
}
if z.BrojDokumenta == "P-1" && (z.Izvor != "prodaja" || z.IzvorID == nil || *z.IzvorID != 7) {
t.Errorf("auto zapis: izvor=%q izvor_id=%v, očekivano prodaja/7", z.Izvor, z.IzvorID)
}
}
// ObrisiPoIzvoru briše samo vezani auto zapis, ručni ostaje
if err := repo.ObrisiPoIzvoru(ctx, "prodaja", 7); err != nil {
t.Fatalf("ObrisiPoIzvoru: %v", err)
}
preostali, _ := repo.Lista(ctx, time.Time{}, time.Time{})
if len(preostali) != 1 || preostali[0].BrojDokumenta != "R-1" {
t.Errorf("posle ObrisiPoIzvoru očekivan samo ručni zapis, dobijeno %d", len(preostali))
}
}
+101
View File
@@ -0,0 +1,101 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/model"
)
// PdvStopaRepo je SQLite implementacija PdvStopaRepository interfejsa
type PdvStopaRepo struct {
db *sql.DB
}
// NoviPdvStopaRepo kreira novi PdvStopaRepo
func NoviPdvStopaRepo(db *sql.DB) *PdvStopaRepo {
return &PdvStopaRepo{db: db}
}
// Lista vraća stope iz šifarnika; ako je samoAktivne true, izostavlja arhivirane
func (r *PdvStopaRepo) Lista(ctx context.Context, samoAktivne bool) ([]model.PdvStopa, error) {
upit := `
SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa
FROM pdv_stope
WHERE 1=1`
if samoAktivne {
upit += " AND aktivna = 1"
}
upit += " ORDER BY redosled ASC, stopa DESC"
redovi, err := r.db.QueryContext(ctx, upit)
if err != nil {
return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.PdvStopa
for redovi.Next() {
var s model.PdvStopa
if err := redovi.Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa); err != nil {
return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: scan: %w", err)
}
rezultat = append(rezultat, s)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: %w", err)
}
return rezultat, nil
}
// DohvatiID vraća jednu stopu po identifikatoru
func (r *PdvStopaRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvStopa, error) {
var s model.PdvStopa
err := r.db.QueryRowContext(ctx, `
SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa
FROM pdv_stope WHERE id = ?`, id).
Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa)
if err != nil {
return nil, fmt.Errorf("ntech: PdvStopaRepo.DohvatiID: %w", err)
}
return &s, nil
}
// Kreiraj dodaje novu stopu i vraća njen id
func (r *PdvStopaRepo) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error) {
rez, err := r.db.ExecContext(ctx, `
INSERT INTO pdv_stope (naziv, stopa, oznaka, aktivna, redosled)
VALUES (?, ?, ?, ?, ?)`,
s.Naziv, s.Stopa, s.Oznaka, s.Aktivna, s.Redosled)
if err != nil {
return 0, fmt.Errorf("ntech: PdvStopaRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: PdvStopaRepo.Kreiraj: id: %w", err)
}
return id, nil
}
// Izmeni menja podatke postojeće stope (osim datuma unosa)
func (r *PdvStopaRepo) Izmeni(ctx context.Context, s *model.PdvStopa) error {
_, err := r.db.ExecContext(ctx, `
UPDATE pdv_stope
SET naziv = ?, stopa = ?, oznaka = ?, aktivna = ?, redosled = ?
WHERE id = ?`,
s.Naziv, s.Stopa, s.Oznaka, s.Aktivna, s.Redosled, s.ID)
if err != nil {
return fmt.Errorf("ntech: PdvStopaRepo.Izmeni: %w", err)
}
return nil
}
// PostaviAktivnu arhivira (false) ili vraća u upotrebu (true) stopu, bez brisanja
func (r *PdvStopaRepo) PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error {
_, err := r.db.ExecContext(ctx, "UPDATE pdv_stope SET aktivna = ? WHERE id = ?", aktivna, id)
if err != nil {
return fmt.Errorf("ntech: PdvStopaRepo.PostaviAktivnu: %w", err)
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package sqlite
import (
"context"
"testing"
"ntech/internal/model"
)
func TestPdvStopaRepo(t *testing.T) {
db := testDB(t)
repo := NoviPdvStopaRepo(db)
ctx := context.Background()
// migracija 040 seeduje 3 stope; redosled ASC → opšta(20), posebna(10), oslobođeno(0)
t.Run("seed iz migracije", func(t *testing.T) {
stope, err := repo.Lista(ctx, true)
if err != nil {
t.Fatalf("Lista: %v", err)
}
if len(stope) != 3 {
t.Fatalf("očekivano 3 seed stope, dobijeno %d", len(stope))
}
if stope[0].Stopa != 20 || stope[0].Oznaka != "opsta" {
t.Errorf("prva stopa = %v %q, očekivano 20 opsta", stope[0].Stopa, stope[0].Oznaka)
}
if stope[2].Stopa != 0 || stope[2].Oznaka != "oslobodjeno" {
t.Errorf("treća stopa = %v %q, očekivano 0 oslobodjeno", stope[2].Stopa, stope[2].Oznaka)
}
})
var novaID int64
t.Run("kreiraj i dohvati", func(t *testing.T) {
id, err := repo.Kreiraj(ctx, &model.PdvStopa{
Naziv: "Test stopa", Stopa: 11.5, Oznaka: "posebna", Aktivna: true, Redosled: 9,
})
if err != nil {
t.Fatalf("Kreiraj: %v", err)
}
novaID = id
s, err := repo.DohvatiID(ctx, id)
if err != nil {
t.Fatalf("DohvatiID: %v", err)
}
if s.Naziv != "Test stopa" || s.Stopa != 11.5 || s.Oznaka != "posebna" || !s.Aktivna || s.Redosled != 9 {
t.Errorf("dohvaćeno %+v, ne poklapa se sa unetim", s)
}
})
t.Run("izmeni", func(t *testing.T) {
err := repo.Izmeni(ctx, &model.PdvStopa{
ID: novaID, Naziv: "Izmenjena", Stopa: 12, Oznaka: "opsta", Aktivna: true, Redosled: 5,
})
if err != nil {
t.Fatalf("Izmeni: %v", err)
}
s, err := repo.DohvatiID(ctx, novaID)
if err != nil {
t.Fatalf("DohvatiID posle izmene: %v", err)
}
if s.Naziv != "Izmenjena" || s.Stopa != 12 || s.Oznaka != "opsta" || s.Redosled != 5 {
t.Errorf("posle izmene %+v, ne poklapa se sa izmenom", s)
}
})
t.Run("arhiviranje izostavlja iz aktivnih ali zadržava u punoj listi", func(t *testing.T) {
if err := repo.PostaviAktivnu(ctx, novaID, false); err != nil {
t.Fatalf("PostaviAktivnu: %v", err)
}
aktivne, err := repo.Lista(ctx, true)
if err != nil {
t.Fatalf("Lista aktivne: %v", err)
}
for _, s := range aktivne {
if s.ID == novaID {
t.Errorf("arhivirana stopa %d ne sme biti među aktivnima", novaID)
}
}
sve, err := repo.Lista(ctx, false)
if err != nil {
t.Fatalf("Lista sve: %v", err)
}
nadjena := false
for _, s := range sve {
if s.ID == novaID {
nadjena = true
if s.Aktivna {
t.Errorf("stopa %d treba da bude aktivna=false posle arhiviranja", novaID)
}
}
}
if !nadjena {
t.Errorf("arhivirana stopa %d mora ostati u punoj listi (ne briše se)", novaID)
}
})
}
+20 -1
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
@@ -85,7 +86,15 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) {
} }
dobavljac, greska := parseFormuDobavljaca(r) dobavljac, greska := parseFormuDobavljaca(r)
jeAjax := r.Header.Get("X-Requested-With") == "fetch"
if greska != "" { if greska != "" {
// fetch zahtev (iz modala u nabavci) dobija JSON grešku
if jeAjax {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"greska":%q}`, greska)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "dobavljaci" ps.Stranica = "dobavljaci"
@@ -99,11 +108,19 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, err := h.DobavljaciRepo.Kreiraj(r.Context(), &dobavljac); err != nil { id, err := h.DobavljaciRepo.Kreiraj(r.Context(), &dobavljac)
if err != nil {
http.Error(w, "Greška pri čuvanju dobavljača", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju dobavljača", http.StatusInternalServerError)
return return
} }
// fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog dobavljača
if jeAjax {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"id":%d,"naziv":%q}`, id, dobavljac.Naziv)
return
}
http.Redirect(w, r, "/dobavljaci?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/dobavljaci?sacuvano=1", http.StatusSeeOther)
} }
@@ -214,6 +231,8 @@ func parseFormuDobavljaca(r *http.Request) (model.Dobavljac, string) {
KontaktOsoba: strings.TrimSpace(r.FormValue("kontakt_osoba")), KontaktOsoba: strings.TrimSpace(r.FormValue("kontakt_osoba")),
Telefon: strings.TrimSpace(r.FormValue("telefon")), Telefon: strings.TrimSpace(r.FormValue("telefon")),
Email: email, Email: email,
PIB: strings.TrimSpace(r.FormValue("pib")),
Mesto: strings.TrimSpace(r.FormValue("mesto")),
Napomena: strings.TrimSpace(r.FormValue("napomena")), Napomena: strings.TrimSpace(r.FormValue("napomena")),
}, "" }, ""
} }
+26
View File
@@ -1,12 +1,14 @@
package handler package handler
import ( import (
"context"
"database/sql" "database/sql"
"html/template" "html/template"
"io/fs" "io/fs"
"net/http" "net/http"
"sync" "sync"
"ntech/internal/config"
"ntech/internal/db" "ntech/internal/db"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
@@ -34,6 +36,10 @@ type Handler struct {
PokusajiRepo db.PokusajiPrijaveRepository PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository DozvoleRepo db.DozvoleRepository
PdvStopeRepo db.PdvStopaRepository
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
Verzija string Verzija string
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template Templates map[string]*template.Template
@@ -93,6 +99,10 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza), PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza), LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()), DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza),
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
} }
} }
@@ -118,6 +128,20 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB) h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB) h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije()) h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
h.PdvStopeRepo = sqlite.NoviPdvStopaRepo(novaDB)
h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB)
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
h.NivelacijaRepo = sqlite.NoviNivelacijaRepo(novaDB)
}
// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu.
// Koristi se pri automatskom punjenju KIR/KPR iz prodaje/nabavke.
func (h *Handler) modulUkljucen(ctx context.Context, modul string) bool {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB)
if err != nil {
return false
}
return config.ModulUkljucen(podesavanja, modul)
} }
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju. // zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
@@ -159,6 +183,8 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
ps.CsrfToken = middleware.CsrfToken(r.Context()) ps.CsrfToken = middleware.CsrfToken(r.Context())
ps.AssetV = h.AssetV ps.AssetV = h.AssetV
ps.Flash = middleware.GetFlash(r, h.DB) ps.Flash = middleware.GetFlash(r, h.DB)
// uključeni zakonski moduli prema profilu firme — šabloni ih koriste za uslovni meni
ps.Moduli = config.SviModuli(podesavanja)
// logika pozadine: // logika pozadine:
// - lična pozadina → uvek se prikazuje i forsira tamnu temu, bez obzira na KoristiLokalnuTemu // - lična pozadina → uvek se prikazuje i forsira tamnu temu, bez obzira na KoristiLokalnuTemu
+52
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/model" "ntech/internal/model"
@@ -64,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"),
Marza: parsirajMarzu(r.FormValue("marza")),
} }
if _, err := h.KategorijeRepo.Kreiraj(r.Context(), k); err != nil { if _, err := h.KategorijeRepo.Kreiraj(r.Context(), k); err != nil {
@@ -74,6 +76,56 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/magacin/kategorije?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/magacin/kategorije?sacuvano=1", http.StatusSeeOther)
} }
// IzmeniKategoriju prima POST i ažurira naziv, opis i maržu postojeće kategorije
func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.izmeni"); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "Neispravan ID kategorije", http.StatusBadRequest)
return
}
naziv := r.FormValue("naziv")
if naziv == "" {
http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther)
return
}
k := &model.Kategorija{
ID: id,
Naziv: naziv,
Opis: r.FormValue("opis"),
Marza: parsirajMarzu(r.FormValue("marza")),
}
if err := h.KategorijeRepo.Izmeni(r.Context(), k); err != nil {
http.Error(w, "Greška pri čuvanju izmene kategorije", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/magacin/kategorije?sacuvano=1", http.StatusSeeOther)
}
// parsirajMarzu pretvara tekst iz forme u *float64; prazno/neispravno → nil (NULL u bazi)
func parsirajMarzu(s string) *float64 {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return nil
}
return &v
}
// 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 {
+5
View File
@@ -25,6 +25,11 @@ var saSidebar = []string{
"magacin", "magacin_forma", "magacin", "magacin_forma",
"nabavke", "nabavka_forma", "nabavka_detalji", "nabavke", "nabavka_forma", "nabavka_detalji",
"podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem",
"pdv_stope",
"pdv_kir", "pdv_kir_forma",
"pdv_kpr", "pdv_kpr_forma",
"pdv_obracun",
"nivelacije",
"podsetnici", "podsetnik_forma", "podsetnici", "podsetnik_forma",
"profil_tema", "profil_tema",
"prodaja", "prodaja_detalji", "prodaja_forma", "prodaja", "prodaja_detalji", "prodaja_forma",
+1
View File
@@ -249,6 +249,7 @@ func parseFormuKlijenta(r *http.Request) (model.Klijent, string) {
PIB: strings.TrimSpace(r.FormValue("pib")), PIB: strings.TrimSpace(r.FormValue("pib")),
Telefon: strings.TrimSpace(r.FormValue("telefon")), Telefon: strings.TrimSpace(r.FormValue("telefon")),
Email: email, Email: email,
Mesto: strings.TrimSpace(r.FormValue("mesto")),
Napomena: strings.TrimSpace(r.FormValue("napomena")), Napomena: strings.TrimSpace(r.FormValue("napomena")),
}, "" }, ""
} }
+30
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
@@ -182,12 +183,32 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
return return
} }
// stara prodajna cena — za nivelacioni trag ako se promeni kroz izmenu
var staraCena float64
if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil {
staraCena = stari.ProdajnaCena
}
artikal.ID = id artikal.ID = id
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError)
return return
} }
// 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
if _, e := h.NivelacijaRepo.Kreiraj(r.Context(), &model.Nivelacija{
ArtikalID: id,
StaraCena: staraCena,
NovaCena: artikal.ProdajnaCena,
Izvor: "izmena",
KorisnikID: korisnikID,
}); e != nil {
slog.Error("auto-nivelacija pri izmeni artikla nije upisana", "artikal_id", id, "error", e)
}
}
http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther)
} }
@@ -228,6 +249,15 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
artikal.ProdajnaCena = v artikal.ProdajnaCena = v
} }
// marža (%) je opciona; prazno polje ostaje NULL (artikal nasleđuje maržu kategorije/globalnu)
if m := r.FormValue("marza"); m != "" {
v, err := strconv.ParseFloat(m, 64)
if err != nil || v < 0 {
return artikal, "Marža mora biti pozitivan broj."
}
artikal.Marza = &v
}
if katID := r.FormValue("kategorija_id"); katID != "" { if katID := r.FormValue("kategorija_id"); katID != "" {
id, err := strconv.ParseInt(katID, 10, 64) id, err := strconv.ParseInt(katID, 10, 64)
if err == nil { if err == nil {
+143 -12
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -29,6 +30,8 @@ type PodaciFormeNabavke struct {
ArtikliJSON template.JS // JSON niz artikala za Alpine.js — bezbedan za umetanje u <script> ArtikliJSON template.JS // JSON niz artikala za Alpine.js — bezbedan za umetanje u <script>
Dobavljaci []model.Dobavljac Dobavljaci []model.Dobavljac
Kategorije []model.Kategorija // za dropdown u modalu novog artikla Kategorije []model.Kategorija // za dropdown u modalu novog artikla
Marza string // podrazumevana marža (%) za kalkulaciju
PdvObveznik bool // da li firma obračunava PDV (utiče na prodajnu cenu u kalkulaciji)
Greska string Greska string
} }
@@ -37,6 +40,8 @@ type PodaciDetaljiNabavke struct {
model.PodaciStranice model.PodaciStranice
Nabavka model.Nabavka Nabavka model.Nabavka
Stavke []model.StavkaSaArtiklom Stavke []model.StavkaSaArtiklom
Troskovi []model.NabavkaTrosak
UkupanTrosak float64
DobavljacNaziv string DobavljacNaziv string
} }
@@ -45,10 +50,16 @@ func artikalUJSON(artikli []model.ArtikalSaKategorijom) template.JS {
type stavka struct { type stavka struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Naziv string `json:"naziv"` Naziv string `json:"naziv"`
PdvStopa float64 `json:"pdv_stopa"`
Marza *float64 `json:"marza"` // marža artikla; null = nije postavljeno
KategorijaMarza *float64 `json:"kategorija_marza"` // marža kategorije; fallback ako artikal nema
} }
lista := make([]stavka, 0, len(artikli)) lista := make([]stavka, 0, len(artikli))
for _, a := range artikli { for _, a := range artikli {
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv}) lista = append(lista, stavka{
ID: a.ID, Naziv: a.Naziv, PdvStopa: a.PdvStopa,
Marza: a.Marza, KategorijaMarza: a.KategorijaMarza,
})
} }
b, _ := json.Marshal(lista) b, _ := json.Marshal(lista)
return template.JS(b) return template.JS(b)
@@ -116,12 +127,15 @@ func (h *Handler) NovaNabavka(w http.ResponseWriter, r *http.Request) {
ArtikliJSON: artikalUJSON(artikli), ArtikliJSON: artikalUJSON(artikli),
Dobavljaci: dobavljaci, Dobavljaci: dobavljaci,
Kategorije: kategorije, Kategorije: kategorije,
Marza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
PdvObveznik: h.modulUkljucen(r.Context(), "pdv"),
}) })
} }
// SacuvajNabavku prima POST formu, parsira stavke i upisuje nabavku u bazu // SacuvajNabavku prima POST formu, parsira stavke i upisuje nabavku u bazu
func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) { func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "nabavka.dodaj"); !ok { k, ok := h.zahtevajDozvolu(w, r, "nabavka.dodaj")
if !ok {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -129,7 +143,7 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
return return
} }
nabavka, stavke, greska := parseFormuNabavke(r) nabavka, stavke, troskovi, greska := parseFormuNabavke(r)
if greska != "" { if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
artikli, _ := h.Artikli.Lista(r.Context(), db.ArtikalFilter{}) artikli, _ := h.Artikli.Lista(r.Context(), db.ArtikalFilter{})
@@ -144,17 +158,89 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
ArtikliJSON: artikalUJSON(artikli), ArtikliJSON: artikalUJSON(artikli),
Dobavljaci: dobavljaci, Dobavljaci: dobavljaci,
Kategorije: kategorije, Kategorije: kategorije,
Marza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
PdvObveznik: h.modulUkljucen(r.Context(), "pdv"),
Greska: greska, Greska: greska,
}) })
return return
} }
id, err := h.NabavkeRepo.Kreiraj(r.Context(), &nabavka, stavke) id, err := h.NabavkeRepo.Kreiraj(r.Context(), &nabavka, stavke, troskovi)
if err != nil { if err != nil {
http.Error(w, "Greška pri čuvanju nabavke", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju nabavke", http.StatusInternalServerError)
return return
} }
// automatski zavedi u KPR ako je firma PDV obveznik; PDV se izvodi iz stope artikla
if h.modulUkljucen(r.Context(), "pdv") {
var stavkePdv []model.NabavkaStavkaPdv
for _, s := range stavke {
var stopa float64
if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil {
stopa = a.PdvStopa
}
stavkePdv = append(stavkePdv, model.NabavkaStavkaPdv{
Osnovica: float64(s.Kolicina) * s.CenaPoKomadu,
PdvStopa: stopa,
})
}
naziv, pib, mesto := "Nepoznat dobavljač", "", ""
if nabavka.DobavljacID != nil {
if d, e := h.DobavljaciRepo.DohvatiID(r.Context(), *nabavka.DobavljacID); e == nil {
naziv, pib, mesto = d.Naziv, d.PIB, d.Mesto
}
}
nabavka.ID = id
kpr := model.KprIzNabavke(nabavka, naziv, pib, mesto, stavkePdv)
if _, e := h.PdvKprRepo.Kreiraj(r.Context(), &kpr); e != nil {
slog.Error("auto-upis u KPR nije uspeo", "nabavka_id", id, "error", e)
}
}
// kalkulacija: ažuriraj nabavnu i prodajnu cenu artikla iz forme + nivelacioni trag.
// prodajna[] je paralelni niz uz stavke (isti redosled kao artikal_id[]).
prodajne := r.Form["prodajna[]"]
var korisnikID *int64
if k != nil {
korisnikID = &k.ID
}
// kalkulativna nabavna cena po stavci (fakturna + raspodeljeni zavisni trošak) —
// računa se na serveru; bez troškova je jednaka čistoj ceni po komadu.
var ukupanTrosak float64
for _, t := range troskovi {
ukupanTrosak += t.Iznos
}
kalkNabavna := model.RasporediTroskove(stavke, ukupanTrosak, nabavka.MetodRaspodele)
for i, s := range stavke {
if i >= len(prodajne) {
break
}
prodajna, e := strconv.ParseFloat(strings.TrimSpace(prodajne[i]), 64)
if e != nil || prodajna <= 0 {
continue // prazno/nula ne sme da pregazi postojeću cenu
}
// stara prodajna cena — za nivelacioni zapis
var staraProdajna float64
if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil {
staraProdajna = a.ProdajnaCena
}
if e := h.Artikli.AzurirajCene(r.Context(), s.ArtikalID, kalkNabavna[i], prodajna); e != nil {
slog.Error("kalkulacija: ažuriranje cena nije uspelo", "artikal_id", s.ArtikalID, "error", e)
continue
}
if razlika := prodajna - staraProdajna; razlika > 0.005 || razlika < -0.005 {
if _, e := h.NivelacijaRepo.Kreiraj(r.Context(), &model.Nivelacija{
ArtikalID: s.ArtikalID,
StaraCena: staraProdajna,
NovaCena: prodajna,
Izvor: "kalkulacija",
KorisnikID: korisnikID,
}); e != nil {
slog.Error("kalkulacija: nivelacija nije upisana", "artikal_id", s.ArtikalID, "error", e)
}
}
}
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
} }
@@ -178,6 +264,12 @@ func (h *Handler) DetaljiNabavke(w http.ResponseWriter, r *http.Request) {
return return
} }
troskovi, err := h.NabavkeRepo.DohvatiTroskove(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju troškova", http.StatusInternalServerError)
return
}
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil { if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
@@ -196,10 +288,16 @@ func (h *Handler) DetaljiNabavke(w http.ResponseWriter, r *http.Request) {
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "nabavke" ps.Stranica = "nabavke"
ps.NaslovStranice = "Detalji nabavke" ps.NaslovStranice = "Detalji nabavke"
var ukupanTrosak float64
for _, t := range troskovi {
ukupanTrosak += t.Iznos
}
podaci := PodaciDetaljiNabavke{ podaci := PodaciDetaljiNabavke{
PodaciStranice: ps, PodaciStranice: ps,
Nabavka: *nabavka, Nabavka: *nabavka,
Stavke: stavke, Stavke: stavke,
Troskovi: troskovi,
UkupanTrosak: ukupanTrosak,
DobavljacNaziv: dobavljacNaziv, DobavljacNaziv: dobavljacNaziv,
} }
@@ -221,12 +319,17 @@ func (h *Handler) ObrisiNabavku(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri brisanju nabavke", http.StatusInternalServerError) http.Error(w, "Greška pri brisanju nabavke", http.StatusInternalServerError)
return return
} }
// ukloni vezani auto-KPR zapis (ako ga je ova nabavka kreirala)
if err := h.PdvKprRepo.ObrisiPoIzvoru(r.Context(), "nabavka", id); err != nil {
slog.Error("brisanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err)
}
http.Redirect(w, r, "/nabavke?obrisan=1", http.StatusSeeOther) http.Redirect(w, r, "/nabavke?obrisan=1", http.StatusSeeOther)
} }
// parseFormuNabavke čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku // parseFormuNabavke čita zaglavlje, stavke i zavisne troškove iz HTTP forme
func parseFormuNabavke(r *http.Request) (model.Nabavka, []model.StavkaNabavke, string) { // i vraća model, stavke, troškove i eventualnu grešku
func parseFormuNabavke(r *http.Request) (model.Nabavka, []model.StavkaNabavke, []model.NabavkaTrosak, string) {
var nabavka model.Nabavka var nabavka model.Nabavka
// opcioni dobavljač // opcioni dobavljač
@@ -244,28 +347,28 @@ func parseFormuNabavke(r *http.Request) (model.Nabavka, []model.StavkaNabavke, s
cene := r.Form["cena_po_komadu[]"] cene := r.Form["cena_po_komadu[]"]
if len(artikalIDovi) == 0 { if len(artikalIDovi) == 0 {
return nabavka, nil, "Nabavka mora imati najmanje jednu stavku." return nabavka, nil, nil, "Nabavka mora imati najmanje jednu stavku."
} }
if len(artikalIDovi) != len(kolicine) || len(artikalIDovi) != len(cene) { if len(artikalIDovi) != len(kolicine) || len(artikalIDovi) != len(cene) {
return nabavka, nil, "Greška u podacima forme — broj stavki nije ispravan." return nabavka, nil, nil, "Greška u podacima forme — broj stavki nije ispravan."
} }
var stavke []model.StavkaNabavke var stavke []model.StavkaNabavke
for i := range artikalIDovi { for i := range artikalIDovi {
artikalID, err := strconv.ParseInt(strings.TrimSpace(artikalIDovi[i]), 10, 64) artikalID, err := strconv.ParseInt(strings.TrimSpace(artikalIDovi[i]), 10, 64)
if err != nil || artikalID <= 0 { if err != nil || artikalID <= 0 {
return nabavka, nil, "Neispravan artikal u stavci." return nabavka, nil, nil, "Neispravan artikal u stavci."
} }
kolicina, err := strconv.Atoi(strings.TrimSpace(kolicine[i])) kolicina, err := strconv.Atoi(strings.TrimSpace(kolicine[i]))
if err != nil || kolicina <= 0 { if err != nil || kolicina <= 0 {
return nabavka, nil, "Količina mora biti pozitivan broj." return nabavka, nil, nil, "Količina mora biti pozitivan broj."
} }
cena, err := strconv.ParseFloat(strings.TrimSpace(cene[i]), 64) cena, err := strconv.ParseFloat(strings.TrimSpace(cene[i]), 64)
if err != nil || cena < 0 { if err != nil || cena < 0 {
return nabavka, nil, "Cena mora biti pozitivan broj." return nabavka, nil, nil, "Cena mora biti pozitivan broj."
} }
stavke = append(stavke, model.StavkaNabavke{ stavke = append(stavke, model.StavkaNabavke{
@@ -275,7 +378,35 @@ func parseFormuNabavke(r *http.Request) (model.Nabavka, []model.StavkaNabavke, s
}) })
} }
return nabavka, stavke, "" // zavisni troškovi (opcioni, paralelni nizovi); prazni redovi se preskaču
naziviT := r.Form["trosak_naziv[]"]
iznosiT := r.Form["trosak_iznos[]"]
var troskovi []model.NabavkaTrosak
for i := range naziviT {
naziv := strings.TrimSpace(naziviT[i])
if naziv == "" {
continue
}
var iznos float64
if i < len(iznosiT) {
iznos, _ = strconv.ParseFloat(strings.TrimSpace(iznosiT[i]), 64)
}
if iznos <= 0 {
continue
}
troskovi = append(troskovi, model.NabavkaTrosak{Naziv: naziv, Iznos: iznos})
}
// metod raspodele je bitan samo ako ima troškova; default je po vrednosti
if len(troskovi) > 0 {
metod := r.FormValue("metod_raspodele")
if metod != "kolicina" {
metod = "vrednost"
}
nabavka.MetodRaspodele = metod
}
return nabavka, stavke, troskovi, ""
} }
// renderujFormuNabavke renderuje HTML šablon forme za unos nove nabavke // renderujFormuNabavke renderuje HTML šablon forme za unos nove nabavke
+89
View File
@@ -0,0 +1,89 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// PodaciNivelacije su podaci za pregled istorije promene cena
type PodaciNivelacije struct {
model.PodaciStranice
Zapisi []model.Nivelacija
Od string
Do string
}
// Nivelacije renderuje istoriju promena prodajnih cena za izabrani period
func (h *Handler) Nivelacije(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
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
}
odStr := r.URL.Query().Get("od")
doStr := r.URL.Query().Get("do")
zapisi, err := h.NivelacijaRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr))
if err != nil {
http.Error(w, "Greška pri učitavanju nivelacija", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "nivelacije"
ps.NaslovStranice = "Nivelacije — promene cena"
h.renderujTemplate(w, "nivelacije", PodaciNivelacije{
PodaciStranice: ps,
Zapisi: zapisi,
Od: odStr,
Do: doStr,
})
}
// PromeniCenuArtikla menja prodajnu cenu artikla i upisuje nivelacioni zapis (izvor "rucno").
func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni")
if !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 := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
novaCena := parsiraIznos(r.FormValue("nova_cena"))
razlog := strings.TrimSpace(r.FormValue("razlog"))
if novaCena <= 0 {
middleware.SetFlash(w, r, h.DB, "greska", "Nova cena mora biti veća od nule.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
return
}
korisnikID := &k.ID
_, err = h.NivelacijaRepo.PromeniCenu(r.Context(), id, novaCena, razlog, korisnikID)
switch {
case errors.Is(err, sqlite.ErrArtikalNePostoji):
middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.")
case err != nil:
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri promeni cene.")
default:
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajna cena je izmenjena.")
}
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
}
+180
View File
@@ -0,0 +1,180 @@
package handler
import (
"net/http"
"strconv"
"strings"
"time"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// PodaciPdvKir su podaci za pregled knjige izdatih računa
type PodaciPdvKir struct {
model.PodaciStranice
Zapisi []model.PdvKir
Sume model.PdvKirSume
Od string // filter perioda (YYYY-MM-DD), prazno = bez granice
Do string
}
// PodaciPdvKirForma su podaci za formu unosa zapisa KIR
type PodaciPdvKirForma struct {
model.PodaciStranice
Greska string
Danas string // podrazumevani datum u formi
Klijenti []model.Klijent // za izbor kupca iz postojećih klijenata
}
// parsiraDatumOpcionalno vraća datum iz YYYY-MM-DD; prazan string daje nulti datum (bez filtera)
func parsiraDatumOpcionalno(s string) time.Time {
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
if err != nil {
return time.Time{}
}
return t
}
// parsiraIznos čita decimalni broj iz forme (prihvata i zarez); prazno/neispravno daje 0
func parsiraIznos(s string) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(strings.Replace(s, ",", ".", 1)), 64)
if err != nil || v < 0 {
return 0
}
return v
}
// PdvKir renderuje pregled knjige izdatih računa sa sumama po stopama
func (h *Handler) PdvKir(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.pregled"); !ok {
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
}
odStr := r.URL.Query().Get("od")
doStr := r.URL.Query().Get("do")
zapisi, err := h.PdvKirRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr))
if err != nil {
http.Error(w, "Greška pri učitavanju knjige izdatih računa", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "pdv-kir"
ps.NaslovStranice = "KIR — knjiga izdatih računa"
h.renderujTemplate(w, "pdv_kir", PodaciPdvKir{
PodaciStranice: ps,
Zapisi: zapisi,
Sume: model.SumirajKir(zapisi),
Od: odStr,
Do: doStr,
})
}
// NoviPdvKir prikazuje praznu formu za unos zapisa u KIR
func (h *Handler) NoviPdvKir(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !ok {
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
}
// klijenti za izbor kupca; greška se ne prekida (forma radi i sa ručnim unosom)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "pdv-kir"
ps.NaslovStranice = "Novi izlazni račun (KIR)"
h.renderujTemplate(w, "pdv_kir_forma", PodaciPdvKirForma{
PodaciStranice: ps,
Danas: time.Now().Format("2006-01-02"),
Klijenti: klijenti,
})
}
// SacuvajPdvKir prima POST i upisuje novi zapis u KIR
func (h *Handler) SacuvajPdvKir(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
datumPrometa, e1 := time.Parse("2006-01-02", strings.TrimSpace(r.FormValue("datum_prometa")))
datumKnjizenja, e2 := time.Parse("2006-01-02", strings.TrimSpace(r.FormValue("datum_knjizenja")))
brojDokumenta := strings.TrimSpace(r.FormValue("broj_dokumenta"))
kupacNaziv := strings.TrimSpace(r.FormValue("kupac_naziv"))
greska := ""
switch {
case e1 != nil:
greska = "Datum prometa je obavezan i mora biti ispravan."
case e2 != nil:
greska = "Datum knjiženja je obavezan i mora biti ispravan."
case brojDokumenta == "":
greska = "Broj dokumenta je obavezan."
case kupacNaziv == "":
greska = "Naziv kupca je obavezan."
}
if greska != "" {
middleware.SetFlash(w, r, h.DB, "greska", greska)
http.Redirect(w, r, "/pdv/kir/nova", http.StatusSeeOther)
return
}
z := model.PdvKir{
DatumPrometa: datumPrometa,
DatumKnjizenja: datumKnjizenja,
BrojDokumenta: brojDokumenta,
KupacNaziv: kupacNaziv,
KupacPib: strings.TrimSpace(r.FormValue("kupac_pib")),
KupacMesto: strings.TrimSpace(r.FormValue("kupac_mesto")),
OsnovicaOpsta: parsiraIznos(r.FormValue("osnovica_opsta")),
PdvOpsta: parsiraIznos(r.FormValue("pdv_opsta")),
OsnovicaPosebna: parsiraIznos(r.FormValue("osnovica_posebna")),
PdvPosebna: parsiraIznos(r.FormValue("pdv_posebna")),
OslobodenSaPravom: parsiraIznos(r.FormValue("osloboden_sa_pravom")),
OslobodenBezPrava: parsiraIznos(r.FormValue("osloboden_bez_prava")),
Napomena: strings.TrimSpace(r.FormValue("napomena")),
}
// ukupna naknada sa PDV — zbir svih osnovica, PDV-a i oslobođenog prometa (računa server)
z.Ukupno = z.OsnovicaOpsta + z.PdvOpsta + z.OsnovicaPosebna + z.PdvPosebna +
z.OslobodenSaPravom + z.OslobodenBezPrava
if _, err := h.PdvKirRepo.Kreiraj(r.Context(), &z); err != nil {
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Izlazni račun je dodat u KIR.")
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
}
// ObrisiPdvKir briše zapis iz KIR
func (h *Handler) ObrisiPdvKir(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.obrisi"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID zapisa", http.StatusBadRequest)
return
}
if err := h.PdvKirRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KIR.")
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
}
+165
View File
@@ -0,0 +1,165 @@
package handler
import (
"net/http"
"strings"
"time"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// PodaciPdvKpr su podaci za pregled knjige primljenih računa
type PodaciPdvKpr struct {
model.PodaciStranice
Zapisi []model.PdvKpr
Sume model.PdvKprSume
Od string
Do string
}
// PodaciPdvKprForma su podaci za formu unosa zapisa KPR
type PodaciPdvKprForma struct {
model.PodaciStranice
Greska string
Danas string
Dobavljaci []model.Dobavljac // za izbor dobavljača iz postojećih
}
// PdvKpr renderuje pregled knjige primljenih računa sa sumama po stopama
func (h *Handler) PdvKpr(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.pregled"); !ok {
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
}
odStr := r.URL.Query().Get("od")
doStr := r.URL.Query().Get("do")
zapisi, err := h.PdvKprRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr))
if err != nil {
http.Error(w, "Greška pri učitavanju knjige primljenih računa", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "pdv-kpr"
ps.NaslovStranice = "KPR — knjiga primljenih računa"
h.renderujTemplate(w, "pdv_kpr", PodaciPdvKpr{
PodaciStranice: ps,
Zapisi: zapisi,
Sume: model.SumirajKpr(zapisi),
Od: odStr,
Do: doStr,
})
}
// NoviPdvKpr prikazuje praznu formu za unos zapisa u KPR
func (h *Handler) NoviPdvKpr(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !ok {
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
}
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "pdv-kpr"
ps.NaslovStranice = "Novi ulazni račun (KPR)"
h.renderujTemplate(w, "pdv_kpr_forma", PodaciPdvKprForma{
PodaciStranice: ps,
Danas: time.Now().Format("2006-01-02"),
Dobavljaci: dobavljaci,
})
}
// SacuvajPdvKpr prima POST i upisuje novi zapis u KPR
func (h *Handler) SacuvajPdvKpr(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
datumPrometa, e1 := time.Parse("2006-01-02", strings.TrimSpace(r.FormValue("datum_prometa")))
datumKnjizenja, e2 := time.Parse("2006-01-02", strings.TrimSpace(r.FormValue("datum_knjizenja")))
brojDokumenta := strings.TrimSpace(r.FormValue("broj_dokumenta"))
dobavljacNaziv := strings.TrimSpace(r.FormValue("dobavljac_naziv"))
greska := ""
switch {
case e1 != nil:
greska = "Datum prometa je obavezan i mora biti ispravan."
case e2 != nil:
greska = "Datum knjiženja je obavezan i mora biti ispravan."
case brojDokumenta == "":
greska = "Broj dokumenta je obavezan."
case dobavljacNaziv == "":
greska = "Naziv dobavljača je obavezan."
}
if greska != "" {
middleware.SetFlash(w, r, h.DB, "greska", greska)
http.Redirect(w, r, "/pdv/kpr/nova", http.StatusSeeOther)
return
}
z := model.PdvKpr{
DatumPrometa: datumPrometa,
DatumKnjizenja: datumKnjizenja,
BrojDokumenta: brojDokumenta,
DobavljacNaziv: dobavljacNaziv,
DobavljacPib: strings.TrimSpace(r.FormValue("dobavljac_pib")),
DobavljacMesto: strings.TrimSpace(r.FormValue("dobavljac_mesto")),
OsnovicaOpsta: parsiraIznos(r.FormValue("osnovica_opsta")),
PdvOpsta: parsiraIznos(r.FormValue("pdv_opsta")),
OsnovicaPosebna: parsiraIznos(r.FormValue("osnovica_posebna")),
PdvPosebna: parsiraIznos(r.FormValue("pdv_posebna")),
PdvBezOdbitka: parsiraIznos(r.FormValue("pdv_bez_odbitka")),
OslobodenNabavka: parsiraIznos(r.FormValue("osloboden_nabavka")),
Napomena: strings.TrimSpace(r.FormValue("napomena")),
Uvoz: r.FormValue("uvoz") == "1",
}
// datum plaćanja je opcionalan
if dp := parsiraDatumOpcionalno(r.FormValue("datum_placanja")); !dp.IsZero() {
z.DatumPlacanja = &dp
}
// ukupna vrednost računa — zbir osnovica, PDV-a i oslobođene nabavke (računa server)
z.Ukupno = z.OsnovicaOpsta + z.PdvOpsta + z.OsnovicaPosebna + z.PdvPosebna +
z.PdvBezOdbitka + z.OslobodenNabavka
if _, err := h.PdvKprRepo.Kreiraj(r.Context(), &z); err != nil {
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Ulazni račun je dodat u KPR.")
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
}
// ObrisiPdvKpr briše zapis iz KPR
func (h *Handler) ObrisiPdvKpr(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.obrisi"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID zapisa", http.StatusBadRequest)
return
}
if err := h.PdvKprRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KPR.")
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
}
+87
View File
@@ -0,0 +1,87 @@
package handler
import (
"net/http"
"time"
"ntech/internal/db/sqlite"
"ntech/internal/model"
)
// PodaciPdvObracun su podaci za stranicu obračuna PDV za period
type PodaciPdvObracun struct {
model.PodaciStranice
Od string
Do string
KirSume model.PdvKirSume
KprSume model.PdvKprSume
Obracun model.PdvObracun
PPPDV model.PPPDV
}
// PdvObracunStranica računa obavezu PDV za izabrani period.
// Kada period nije zadat, podrazumevano se uzima tekući mesec.
func (h *Handler) PdvObracunStranica(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "pdv.pregled"); !ok {
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
}
odStr := r.URL.Query().Get("od")
doStr := r.URL.Query().Get("do")
// podrazumevani period: tekući mesec (od prvog do poslednjeg dana)
if odStr == "" && doStr == "" {
sada := time.Now()
prvi := time.Date(sada.Year(), sada.Month(), 1, 0, 0, 0, 0, sada.Location())
poslednji := prvi.AddDate(0, 1, -1)
odStr = prvi.Format("2006-01-02")
doStr = poslednji.Format("2006-01-02")
}
od := parsiraDatumOpcionalno(odStr)
do := parsiraDatumOpcionalno(doStr)
kirZapisi, err := h.PdvKirRepo.Lista(r.Context(), od, do)
if err != nil {
http.Error(w, "Greška pri učitavanju knjige izdatih računa", http.StatusInternalServerError)
return
}
kprZapisi, err := h.PdvKprRepo.Lista(r.Context(), od, do)
if err != nil {
http.Error(w, "Greška pri učitavanju knjige primljenih računa", http.StatusInternalServerError)
return
}
kirSume := model.SumirajKir(kirZapisi)
kprSume := model.SumirajKpr(kprZapisi)
// razdvajamo KPR na domaće i uvozne — uvoz se u PPPDV mapira u polja 006/106,
// domaća nabavka u 008/108; obračun obaveze ostaje na ukupnom KPR-u (uvozni PDV je odbitni)
var kprDomaci, kprUvozni []model.PdvKpr
for _, z := range kprZapisi {
if z.Uvoz {
kprUvozni = append(kprUvozni, z)
} else {
kprDomaci = append(kprDomaci, z)
}
}
kprDomaceSume := model.SumirajKpr(kprDomaci)
kprUvozSume := model.SumirajKpr(kprUvozni)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "pdv-obracun"
ps.NaslovStranice = "PDV obračun"
h.renderujTemplate(w, "pdv_obracun", PodaciPdvObracun{
PodaciStranice: ps,
Od: odStr,
Do: doStr,
KirSume: kirSume,
KprSume: kprSume,
Obracun: model.ObracunajPdv(kirSume, kprSume),
PPPDV: model.MapirajPPPDV(kirSume, kprDomaceSume, kprUvozSume),
})
}
+160
View File
@@ -0,0 +1,160 @@
package handler
import (
"net/http"
"strconv"
"strings"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// validneOznakeStope su dozvoljene oznake PDV stope (vrsta po zakonu)
var validneOznakeStope = map[string]bool{
"opsta": true,
"posebna": true,
"oslobodjeno": true,
}
// PodaciPdvStope su podaci za stranicu „Kalkulacija i PDV" (marža + šifarnik PDV stopa)
type PodaciPdvStope struct {
model.PodaciStranice
Stope []model.PdvStopa
KalkulacijaMarza string // podrazumevana marža (%) za kalkulaciju
}
// PdvStope renderuje stranicu „Kalkulacija i PDV": podešavanje podrazumevane marže
// i šifarnik PDV stopa (sve stope, uključujući arhivirane)
func (h *Handler) PdvStope(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok {
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
}
stope, err := h.PdvStopeRepo.Lista(r.Context(), false)
if err != nil {
http.Error(w, "Greška pri učitavanju PDV stopa", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "podesavanja-kalkulacija-pdv"
ps.NaslovStranice = "Kalkulacija i PDV"
h.renderujTemplate(w, "pdv_stope", PodaciPdvStope{
PodaciStranice: ps,
Stope: stope,
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
})
}
// parsePdvStopuForma čita i proverava polja forme; vraća popunjenu stopu i poruku o grešci
func parsePdvStopuForma(r *http.Request) (model.PdvStopa, string) {
naziv := strings.TrimSpace(r.FormValue("naziv"))
oznaka := strings.TrimSpace(r.FormValue("oznaka"))
stopaTekst := strings.TrimSpace(strings.Replace(r.FormValue("stopa"), ",", ".", 1))
redosled, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("redosled")))
if naziv == "" {
return model.PdvStopa{}, "Naziv stope je obavezan."
}
if !validneOznakeStope[oznaka] {
return model.PdvStopa{}, "Oznaka mora biti opšta, posebna ili oslobođeno."
}
stopa, err := strconv.ParseFloat(stopaTekst, 64)
if err != nil || stopa < 0 || stopa > 100 {
return model.PdvStopa{}, "Stopa mora biti broj između 0 i 100."
}
return model.PdvStopa{
Naziv: naziv,
Stopa: stopa,
Oznaka: oznaka,
Aktivna: true,
Redosled: redosled,
}, ""
}
// DodajPdvStopu prima POST i upisuje novu stopu u šifarnik
func (h *Handler) DodajPdvStopu(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
stopa, greska := parsePdvStopuForma(r)
if greska != "" {
middleware.SetFlash(w, r, h.DB, "greska", greska)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
return
}
if _, err := h.PdvStopeRepo.Kreiraj(r.Context(), &stopa); err != nil {
http.Error(w, "Greška pri čuvanju PDV stope", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je dodata.")
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
}
// IzmeniPdvStopu prima POST i menja postojeću stopu
func (h *Handler) IzmeniPdvStopu(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID stope", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
stopa, greska := parsePdvStopuForma(r)
if greska != "" {
middleware.SetFlash(w, r, h.DB, "greska", greska)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
return
}
stopa.ID = id
if err := h.PdvStopeRepo.Izmeni(r.Context(), &stopa); err != nil {
http.Error(w, "Greška pri izmeni PDV stope", http.StatusInternalServerError)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je izmenjena.")
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
}
// PromeniAktivnostPdvStope arhivira ili vraća stopu u upotrebu (toggle, bez brisanja)
func (h *Handler) PromeniAktivnostPdvStope(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID stope", http.StatusBadRequest)
return
}
postojeca, err := h.PdvStopeRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "PDV stopa nije pronađena", http.StatusNotFound)
return
}
if err := h.PdvStopeRepo.PostaviAktivnu(r.Context(), id, !postojeca.Aktivna); err != nil {
http.Error(w, "Greška pri promeni statusa PDV stope", http.StatusInternalServerError)
return
}
poruka := "PDV stopa je arhivirana."
if !postojeca.Aktivna {
poruka = "PDV stopa je vraćena u upotrebu."
}
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
}
+30 -1
View File
@@ -30,6 +30,11 @@ type PodaciPodesavanja struct {
PIB string PIB string
LogoTip string LogoTip string
LogoPutanja string LogoPutanja string
// profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale
FirmaPravniOblik string
FirmaPdvObveznik string
FirmaFiskalizacija string
FirmaRezim string
Sacuvano bool Sacuvano bool
Verzija string Verzija string
LogoGreska string LogoGreska string
@@ -37,6 +42,7 @@ type PodaciPodesavanja struct {
Backupi []BackupInfo Backupi []BackupInfo
BackupIntervalSati string BackupIntervalSati string
BackupBrojKopija string BackupBrojKopija string
KalkulacijaMarza string
LoginPozadina string LoginPozadina string
LoginPozadinaOpacity string LoginPozadinaOpacity string
LoginPozadinaBlurPozadine string LoginPozadinaBlurPozadine string
@@ -234,6 +240,11 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
"telefon": r.FormValue("telefon"), "telefon": r.FormValue("telefon"),
"pib": r.FormValue("pib"), "pib": r.FormValue("pib"),
"logo_tip": r.FormValue("logo_tip"), "logo_tip": r.FormValue("logo_tip"),
// profil firme (Faza 0) — radio dugmad uvek šalju vrednost, pa se uredno čuvaju
"firma_pravni_oblik": r.FormValue("firma_pravni_oblik"),
"firma_pdv_obveznik": r.FormValue("firma_pdv_obveznik"),
"firma_fiskalizacija": r.FormValue("firma_fiskalizacija"),
"firma_rezim": r.FormValue("firma_rezim"),
} }
for kljuc, vrednost := range polja { for kljuc, vrednost := range polja {
@@ -278,6 +289,20 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
} }
} }
// podrazumevana marža za kalkulaciju (procenat, 01000)
if v := strings.TrimSpace(r.FormValue("kalkulacija_marza")); v != "" {
marza, err := strconv.ParseFloat(strings.Replace(v, ",", ".", 1), 64)
if err != nil || marza < 0 || marza > 1000 {
middleware.SetFlash(w, r, h.DB, "greska", "Marža mora biti broj između 0 i 1000.")
http.Redirect(w, r, sledeci, http.StatusSeeOther)
return
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "kalkulacija_marza", strconv.FormatFloat(marza, 'f', -1, 64)); 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)
} }
@@ -601,6 +626,10 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
PIB: podesavanja["pib"], PIB: podesavanja["pib"],
LogoTip: podesavanja["logo_tip"], LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"], LogoPutanja: podesavanja["logo_putanja"],
FirmaPravniOblik: vrednostIliDefault(podesavanja, "firma_pravni_oblik", "pausalac"),
FirmaPdvObveznik: vrednostIliDefault(podesavanja, "firma_pdv_obveznik", "ne"),
FirmaFiskalizacija: vrednostIliDefault(podesavanja, "firma_fiskalizacija", "ne"),
FirmaRezim: vrednostIliDefault(podesavanja, "firma_rezim", "samo_evidencija"),
Sacuvano: r.URL.Query().Get("sacuvano") == "1", Sacuvano: r.URL.Query().Get("sacuvano") == "1",
BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno",
Verzija: h.Verzija, Verzija: h.Verzija,
@@ -613,6 +642,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
LoginPozadinaZatamnjenjeKartice: vrednostIliDefault(podesavanja, "login_pozadina_zatamnjenje_kartice", "0"), LoginPozadinaZatamnjenjeKartice: vrednostIliDefault(podesavanja, "login_pozadina_zatamnjenje_kartice", "0"),
BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"), BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"),
BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"), BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
}, nil }, nil
} }
@@ -657,4 +687,3 @@ func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) {
podaci.Stranica = "podesavanja-sistem" podaci.Stranica = "podesavanja-sistem"
h.renderujTemplate(w, "podesavanja_sistem", podaci) h.renderujTemplate(w, "podesavanja_sistem", podaci)
} }
+24
View File
@@ -197,6 +197,22 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
return return
} }
// automatski zavedi u KIR ako je firma PDV obveznik i prodaja je na klijenta (B2B faktura).
// Maloprodaja građanima (bez klijenta) ide zbirno preko fiskalizacije (Faza 3) — preskače se.
if nalog.KlijentID != nil && h.modulUkljucen(r.Context(), "pdv") {
if klijent, e := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID); e == nil {
nalog.ID = id
pib := klijent.PIB
if klijent.Tip != "pravno" {
pib = klijent.JMBG
}
kir := model.KirIzProdaje(nalog, stavke, klijent.PunoIme(), pib, klijent.Mesto)
if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil {
slog.Error("auto-upis u KIR nije uspeo", "prodaja_id", id, "error", e)
}
}
}
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
} }
@@ -319,6 +335,10 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError) http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
return return
} }
// ukloni vezani auto-KIR zapis (ako ga je ova prodaja kreirala)
if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
}
http.Redirect(w, r, "/prodaja?obrisan=1", http.StatusSeeOther) http.Redirect(w, r, "/prodaja?obrisan=1", http.StatusSeeOther)
} }
@@ -408,6 +428,10 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return return
} }
// stornirana prodaja ne ulazi u PDV — ukloni vezani auto-KIR zapis
if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.") middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
} }
+8 -1
View File
@@ -10,6 +10,7 @@ var sveAkcije = []string{
"artikal.premesti", "artikal.premesti",
"kategorija.pregled", "kategorija.pregled",
"kategorija.dodaj", "kategorija.dodaj",
"kategorija.izmeni",
"kategorija.obrisi", "kategorija.obrisi",
"nabavka.pregled", "nabavka.pregled",
"nabavka.dodaj", "nabavka.dodaj",
@@ -38,6 +39,9 @@ var sveAkcije = []string{
"backup.pokreni", "backup.pokreni",
"tema.lokalno", "tema.lokalno",
"dashboard.prihod", "dashboard.prihod",
"pdv.pregled",
"pdv.dodaj",
"pdv.obrisi",
} }
// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu // SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu
@@ -59,7 +63,7 @@ func ImaDozvolu(uloga, akcija string) bool {
"artikal.obrisi", "artikal.premesti": "artikal.obrisi", "artikal.premesti":
return true return true
// kategorija // kategorija
case "kategorija.pregled", "kategorija.dodaj", "kategorija.obrisi": case "kategorija.pregled", "kategorija.dodaj", "kategorija.izmeni", "kategorija.obrisi":
return true return true
// nabavka // nabavka
case "nabavka.pregled", "nabavka.dodaj", "nabavka.obrisi": case "nabavka.pregled", "nabavka.dodaj", "nabavka.obrisi":
@@ -95,6 +99,9 @@ func ImaDozvolu(uloga, akcija string) bool {
// dashboard — prihod samo admin+ // dashboard — prihod samo admin+
case "dashboard.prihod": case "dashboard.prihod":
return true return true
// PDV evidencija (KIR/KPR) — administrativno, radnik nema
case "pdv.pregled", "pdv.dodaj", "pdv.obrisi":
return true
} }
return false return false
+27
View File
@@ -0,0 +1,27 @@
package middleware
import (
"context"
"net/http"
)
// RequireModul je chi middleware koji propušta zahtev samo ako je traženi zakonski
// modul uključen za firmu (prema profilu firme iz podešavanja). Ovo je sloj IZNAD
// RBAC-a: „da li firma uopšte koristi modul", nezavisno od „da li korisnik sme"
// (RequireDozvola). Zahtev mora proći oba sloja.
//
// Provera se prosleđuje kao funkcija (proveri) da paket middleware ne zavisi od
// config/sqlite — isti obrazac kao RequireDozvola. U praksi je to closure koja
// učita podešavanja i pozove config.ModulUkljucen.
func RequireModul(proveri func(ctx context.Context, modul string) bool, modul string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !proveri(r.Context(), modul) {
postaviFlashGresku(w, "Ovaj modul nije uključen za vašu firmu.")
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
}
+3
View File
@@ -14,6 +14,7 @@ type Artikal struct {
NabavnaCena float64 NabavnaCena float64
ProdajnaCena float64 ProdajnaCena float64
PdvStopa float64 PdvStopa float64
Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno
Napomena string Napomena string
DatumUnosa time.Time DatumUnosa time.Time
} }
@@ -33,11 +34,13 @@ type Kategorija struct {
ID int64 ID int64
Naziv string Naziv string
Opis string Opis string
Marza *float64 // podrazumevana marža (%) za artikle ove kategorije; NULL = nije postavljeno
} }
// ArtikalSaKategorijom je artikal sa nazivom kategorije — za prikaz u tabeli // ArtikalSaKategorijom je artikal sa nazivom kategorije — za prikaz u tabeli
type ArtikalSaKategorijom struct { type ArtikalSaKategorijom struct {
Artikal Artikal
KategorijaNaziv string KategorijaNaziv string
KategorijaMarza *float64 // marža kategorije; za fallback predloga marže pri nabavci
KriticnaZaliha bool KriticnaZaliha bool
} }
+2
View File
@@ -9,6 +9,8 @@ type Dobavljac struct {
KontaktOsoba string KontaktOsoba string
Telefon string Telefon string
Email string Email string
PIB string
Mesto string
Napomena string Napomena string
DatumUnosa time.Time DatumUnosa time.Time
} }
+1
View File
@@ -16,6 +16,7 @@ type Klijent struct {
PIB string PIB string
Telefon string Telefon string
Email string Email string
Mesto string
Napomena string Napomena string
DatumUnosa time.Time DatumUnosa time.Time
} }
+51 -1
View File
@@ -1,6 +1,9 @@
package model package model
import "time" import (
"math"
"time"
)
// Nabavka predstavlja zaglavlje jedne nabavke // Nabavka predstavlja zaglavlje jedne nabavke
type Nabavka struct { type Nabavka struct {
@@ -8,9 +11,18 @@ type Nabavka struct {
DobavljacID *int64 DobavljacID *int64
Napomena string Napomena string
Ukupno float64 Ukupno float64
MetodRaspodele string // "vrednost" ili "kolicina"; prazno = nema zavisnih troškova
Datum time.Time Datum time.Time
} }
// NabavkaTrosak je jedna stavka zavisnih troškova nabavke (npr. prevoz, carina)
type NabavkaTrosak struct {
ID int64
NabavkaID int64
Naziv string
Iznos float64
}
// StavkaNabavke predstavlja jednu liniju (artikal) unutar nabavke // StavkaNabavke predstavlja jednu liniju (artikal) unutar nabavke
type StavkaNabavke struct { type StavkaNabavke struct {
ID int64 ID int64
@@ -21,6 +33,44 @@ type StavkaNabavke struct {
Ukupno float64 Ukupno float64
} }
// RasporediTroskove raspodeljuje ukupan zavisni trošak na stavke nabavke i vraća
// kalkulativnu nabavnu cenu po komadu za svaku stavku (isti redosled kao ulazne stavke).
// metod "kolicina" deli trošak po broju komada; svaka druga vrednost (uklj. "vrednost")
// deli po nabavnoj vrednosti stavke (količina × cena).
// Ako nema troška ili je osnovica nula, vraća nepromenjenu cenu po komadu.
func RasporediTroskove(stavke []StavkaNabavke, ukupanTrosak float64, metod string) []float64 {
kalk := make([]float64, len(stavke))
// osnovica raspodele po izabranom metodu
var osnovica float64
for _, s := range stavke {
if metod == "kolicina" {
osnovica += float64(s.Kolicina)
} else {
osnovica += float64(s.Kolicina) * s.CenaPoKomadu
}
}
for i, s := range stavke {
kalk[i] = s.CenaPoKomadu
// bez troška, bez osnovice ili bez količine — nema šta da se raspodeli
if ukupanTrosak <= 0 || osnovica <= 0 || s.Kolicina == 0 {
continue
}
var udeo float64
if metod == "kolicina" {
udeo = float64(s.Kolicina) / osnovica
} else {
udeo = (float64(s.Kolicina) * s.CenaPoKomadu) / osnovica
}
trosakPoKomadu := (ukupanTrosak * udeo) / float64(s.Kolicina)
// zaokruženo na 2 decimale — kalkulativna nabavna je cena koja se čuva
kalk[i] = math.Round((s.CenaPoKomadu+trosakPoKomadu)*100) / 100
}
return kalk
}
// NabavkaSaDetaljem je nabavka sa nazivom dobavljača — za prikaz u listi // NabavkaSaDetaljem je nabavka sa nazivom dobavljača — za prikaz u listi
type NabavkaSaDetaljem struct { type NabavkaSaDetaljem struct {
Nabavka Nabavka
+74
View File
@@ -0,0 +1,74 @@
package model
import "testing"
// proverava raspodelu zavisnih troškova na stavke nabavke po oba metoda
func TestRasporediTroskove(t *testing.T) {
// dve stavke: A = 10×100 (vrednost 1000), B = 10×200 (vrednost 2000)
stavke := []StavkaNabavke{
{Kolicina: 10, CenaPoKomadu: 100},
{Kolicina: 10, CenaPoKomadu: 200},
}
slucajevi := []struct {
naziv string
stavke []StavkaNabavke
trosak float64
metod string
ocekuje []float64
}{
{
naziv: "bez troška — cena nepromenjena",
stavke: stavke,
trosak: 0,
metod: "vrednost",
ocekuje: []float64{100, 200},
},
{
// trošak 300 po vrednosti: A nosi 1/3 (100 → 10/kom), B nosi 2/3 (200 → 20/kom)
naziv: "po vrednosti",
stavke: stavke,
trosak: 300,
metod: "vrednost",
ocekuje: []float64{110, 220},
},
{
// trošak 300 po količini: svaka stavka 1/2 (150 → 15/kom)
naziv: "po količini",
stavke: stavke,
trosak: 300,
metod: "kolicina",
ocekuje: []float64{115, 215},
},
{
// osnovica po vrednosti je 0 (sve cene 0) — nema raspodele
naziv: "nulta osnovica vrednosti",
stavke: []StavkaNabavke{{Kolicina: 5, CenaPoKomadu: 0}},
trosak: 100,
metod: "vrednost",
ocekuje: []float64{0},
},
{
// stavka bez količine se preskače (bez deljenja nulom)
naziv: "količina nula",
stavke: []StavkaNabavke{{Kolicina: 0, CenaPoKomadu: 50}},
trosak: 100,
metod: "kolicina",
ocekuje: []float64{50},
},
}
for _, s := range slucajevi {
t.Run(s.naziv, func(t *testing.T) {
dobijeno := RasporediTroskove(s.stavke, s.trosak, s.metod)
if len(dobijeno) != len(s.ocekuje) {
t.Fatalf("dužina = %d, očekivano %d", len(dobijeno), len(s.ocekuje))
}
for i := range s.ocekuje {
if dobijeno[i] != s.ocekuje[i] {
t.Errorf("stavka %d: dobijeno %.2f, očekivano %.2f", i, dobijeno[i], s.ocekuje[i])
}
}
})
}
}
+37
View File
@@ -0,0 +1,37 @@
package model
import "time"
// Nivelacija je zapis o promeni prodajne cene artikla (revizioni trag).
// Razlika i procenat se izvode iz stare i nove cene (ne čuvaju se u bazi).
type Nivelacija struct {
ID int64
ArtikalID int64
ArtikalNaziv string // iz JOIN-a, radi prikaza; nije kolona u nivelacije
StaraCena float64
NovaCena float64
Razlog string
Izvor string // "rucno" | "izmena" | "kalkulacija"
KorisnikID *int64
KorisnikIme string // iz JOIN-a, radi prikaza
Datum time.Time
DatumUnosa time.Time
}
// Razlika vraća apsolutnu promenu cene (nova stara); negativna znači pojeftinjenje.
func (n Nivelacija) Razlika() float64 {
return n.NovaCena - n.StaraCena
}
// Procenat vraća procentualnu promenu u odnosu na staru cenu (0 ako je stara cena 0).
func (n Nivelacija) Procenat() float64 {
if n.StaraCena == 0 {
return 0
}
return (n.NovaCena - n.StaraCena) / n.StaraCena * 100
}
// Poskupljenje vraća true kada je nova cena veća od stare.
func (n Nivelacija) Poskupljenje() bool {
return n.NovaCena > n.StaraCena
}
+23
View File
@@ -0,0 +1,23 @@
package model
import "testing"
func TestNivelacija(t *testing.T) {
// poskupljenje: 100 → 120 = +20, +20%
n := Nivelacija{StaraCena: 100, NovaCena: 120}
if !blizu(n.Razlika(), 20) || !blizu(n.Procenat(), 20) || !n.Poskupljenje() {
t.Errorf("razlika=%v procenat=%v poskupljenje=%v, očekivano 20/20/true", n.Razlika(), n.Procenat(), n.Poskupljenje())
}
// pojeftinjenje: 200 → 150 = -50, -25%
n2 := Nivelacija{StaraCena: 200, NovaCena: 150}
if !blizu(n2.Razlika(), -50) || !blizu(n2.Procenat(), -25) || n2.Poskupljenje() {
t.Errorf("razlika=%v procenat=%v poskupljenje=%v, očekivano -50/-25/false", n2.Razlika(), n2.Procenat(), n2.Poskupljenje())
}
// stara cena 0 → procenat 0 (bez deljenja nulom)
n3 := Nivelacija{StaraCena: 0, NovaCena: 80}
if !blizu(n3.Procenat(), 0) {
t.Errorf("procenat=%v, očekivano 0 kada je stara cena 0", n3.Procenat())
}
}
+332
View File
@@ -0,0 +1,332 @@
package model
import (
"fmt"
"math"
"time"
)
// PdvKir je jedan zapis u knjizi izdatih računa (izlazni PDV).
// Iznosi se vode po vrsti stope (opšta/posebna) — vidi migraciju 041.
type PdvKir struct {
ID int64
DatumPrometa time.Time
DatumKnjizenja time.Time
BrojDokumenta string
KupacNaziv string
KupacPib string
KupacMesto string
OsnovicaOpsta float64
PdvOpsta float64
OsnovicaPosebna float64
PdvPosebna float64
OslobodenSaPravom float64
OslobodenBezPrava float64
Ukupno float64
Napomena string
Izvor string // "rucno" | "prodaja" | "nabavka"
IzvorID *int64 // id izvornog naloga (nil za ručni unos)
DatumUnosa time.Time
}
// OslobodenUkupno vraća zbir oslobođenog prometa (sa i bez prava na odbitak).
func (k PdvKir) OslobodenUkupno() float64 {
return k.OslobodenSaPravom + k.OslobodenBezPrava
}
// OznakaPoreskogBroja vraća „JMBG" ako uneti broj ima 13 cifara (fizičko lice),
// inače „PIB" (pravno lice / preduzetnik — PIB ima 9 cifara).
func (k PdvKir) OznakaPoreskogBroja() string {
cifre := 0
for _, r := range k.KupacPib {
if r >= '0' && r <= '9' {
cifre++
}
}
if cifre == 13 {
return "JMBG"
}
return "PIB"
}
// PdvKirSume su zbirovi kolona KIR-a (za red „ukupno" u pregledu knjige).
type PdvKirSume struct {
OsnovicaOpsta float64
PdvOpsta float64
OsnovicaPosebna float64
PdvPosebna float64
OslobodenSaPravom float64
OslobodenBezPrava float64
Ukupno float64
}
// OslobodenUkupno vraća zbir oslobođenog prometa (sa i bez prava na odbitak).
func (s PdvKirSume) OslobodenUkupno() float64 {
return s.OslobodenSaPravom + s.OslobodenBezPrava
}
// SumirajKir sabira sve kolone iz liste KIR zapisa.
func SumirajKir(zapisi []PdvKir) PdvKirSume {
var s PdvKirSume
for _, z := range zapisi {
s.OsnovicaOpsta += z.OsnovicaOpsta
s.PdvOpsta += z.PdvOpsta
s.OsnovicaPosebna += z.OsnovicaPosebna
s.PdvPosebna += z.PdvPosebna
s.OslobodenSaPravom += z.OslobodenSaPravom
s.OslobodenBezPrava += z.OslobodenBezPrava
s.Ukupno += z.Ukupno
}
return s
}
// KirIzProdaje gradi KIR zapis iz prodaje: stavke se grupišu po PDV stopi
// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena SA PDV,
// pa se osnovica izvodi deljenjem sa (1 + stopa/100).
func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupacPib, kupacMesto string) PdvKir {
id := nalog.ID
k := PdvKir{
DatumPrometa: nalog.Datum,
DatumKnjizenja: nalog.Datum,
BrojDokumenta: nalog.BrojNaloga,
KupacNaziv: kupacNaziv,
KupacPib: kupacPib,
KupacMesto: kupacMesto,
Izvor: "prodaja",
IzvorID: &id,
}
for _, s := range stavke {
ukupnoLinija := float64(s.Kolicina) * s.CenaPoKomadu
osnovica := ukupnoLinija
if s.PdvStopa > 0 {
osnovica = ukupnoLinija / (1 + s.PdvStopa/100)
}
pdv := ukupnoLinija - osnovica
switch s.PdvStopa {
case 20:
k.OsnovicaOpsta += osnovica
k.PdvOpsta += pdv
case 10:
k.OsnovicaPosebna += osnovica
k.PdvPosebna += pdv
default:
// 0% / oslobođeno — osnovica bez PDV-a u oslobođen promet sa pravom na odbitak
k.OslobodenSaPravom += osnovica
}
k.Ukupno += ukupnoLinija
}
return k
}
// PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV).
type PdvKpr struct {
ID int64
DatumPrometa time.Time
DatumKnjizenja time.Time
DatumPlacanja *time.Time // može biti prazan
BrojDokumenta string
DobavljacNaziv string
DobavljacPib string
DobavljacMesto string
OsnovicaOpsta float64
PdvOpsta float64
OsnovicaPosebna float64
PdvPosebna float64
PdvBezOdbitka float64
OslobodenNabavka float64
Ukupno float64
Napomena string
Izvor string // "rucno" | "prodaja" | "nabavka"
IzvorID *int64 // id izvorne nabavke (nil za ručni unos)
Uvoz bool // true = uvoz (JCI) → PPPDV 006/106; false = domaća nabavka → 008/108
DatumUnosa time.Time
}
// OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme).
func (k PdvKpr) OznakaPoreskogBroja() string {
cifre := 0
for _, r := range k.DobavljacPib {
if r >= '0' && r <= '9' {
cifre++
}
}
if cifre == 13 {
return "JMBG"
}
return "PIB"
}
// PdvKprSume su zbirovi kolona KPR-a (za red „ukupno" u pregledu knjige).
type PdvKprSume struct {
OsnovicaOpsta float64
PdvOpsta float64
OsnovicaPosebna float64
PdvPosebna float64
PdvBezOdbitka float64
OslobodenNabavka float64
Ukupno float64
}
// SumirajKpr sabira sve kolone iz liste KPR zapisa.
func SumirajKpr(zapisi []PdvKpr) PdvKprSume {
var s PdvKprSume
for _, z := range zapisi {
s.OsnovicaOpsta += z.OsnovicaOpsta
s.PdvOpsta += z.PdvOpsta
s.OsnovicaPosebna += z.OsnovicaPosebna
s.PdvPosebna += z.PdvPosebna
s.PdvBezOdbitka += z.PdvBezOdbitka
s.OslobodenNabavka += z.OslobodenNabavka
s.Ukupno += z.Ukupno
}
return s
}
// NabavkaStavkaPdv je jedna stavka nabavke sa pripadajućom PDV stopom (iz artikla).
type NabavkaStavkaPdv struct {
Osnovica float64 // nabavna vrednost stavke (cena × količina) — tretira se kao osnovica bez PDV
PdvStopa float64
}
// KprIzNabavke gradi KPR zapis iz nabavke. PDV se izvodi iz stope artikla po stavci
// (⚠ aproksimacija: nabavna cena = osnovica bez PDV; stvaran PDV sa računa dobavljača može
// se razlikovati). Grupiše po stopi (20→opšta, 10→posebna, ostalo→oslobođena nabavka).
// Broj dokumenta je sintetički ("NAB-<id>") jer nabavka ne čuva broj računa dobavljača.
func KprIzNabavke(nabavka Nabavka, dobavljacNaziv, dobavljacPib, dobavljacMesto string, stavke []NabavkaStavkaPdv) PdvKpr {
id := nabavka.ID
k := PdvKpr{
DatumPrometa: nabavka.Datum,
DatumKnjizenja: nabavka.Datum,
BrojDokumenta: fmt.Sprintf("NAB-%d", id),
DobavljacNaziv: dobavljacNaziv,
DobavljacPib: dobavljacPib,
DobavljacMesto: dobavljacMesto,
Napomena: nabavka.Napomena,
Izvor: "nabavka",
IzvorID: &id,
}
for _, s := range stavke {
pdv := s.Osnovica * s.PdvStopa / 100
switch s.PdvStopa {
case 20:
k.OsnovicaOpsta += s.Osnovica
k.PdvOpsta += pdv
case 10:
k.OsnovicaPosebna += s.Osnovica
k.PdvPosebna += pdv
default:
k.OslobodenNabavka += s.Osnovica
}
k.Ukupno += s.Osnovica + pdv
}
return k
}
// PdvObracun je rezultat obračuna PDV za period: izlazni (dugovani) PDV iz KIR,
// prethodni (odbitni) PDV iz KPR i konačna obaveza. Pozitivna obaveza znači iznos
// za uplatu, negativna iznos za povraćaj / prenos poreskog kredita u naredni period.
type PdvObracun struct {
// izlazni (dugovani) PDV — iz KIR, po stopama
IzlazniPdvOpsta float64
IzlazniPdvPosebna float64
IzlazniPdvUkupno float64
// prethodni (odbitni) PDV — iz KPR, po stopama (bez PDV bez prava na odbitak)
OdbitniPdvOpsta float64
OdbitniPdvPosebna float64
OdbitniPdvUkupno float64
// obaveza = izlazni odbitni (>0 za uplatu, <0 za povraćaj/prenos)
Obaveza float64
}
// ZaUplatu vraća true kada postoji obaveza za uplatu (izlazni PDV veći od odbitnog).
func (o PdvObracun) ZaUplatu() bool {
return o.Obaveza > 0
}
// ObavezaApsolutna vraća iznos obaveze bez predznaka (za prikaz povraćaja kao pozitivan broj).
func (o PdvObracun) ObavezaApsolutna() float64 {
if o.Obaveza < 0 {
return -o.Obaveza
}
return o.Obaveza
}
// PPPDV su vrednosti polja zvaničnog obrasca poreske prijave PDV (PPPDV).
// Iznosi su u celim dinarima (obrazac ne dozvoljava decimale). Brojevi polja
// odgovaraju obrascu: 001005/103105 (promet i izlazni PDV), 006009/106109
// (prethodni/odbitni porez), 110 (obaveza = 105 109), Povracaj (polje 11).
type PPPDV struct {
// I. promet dobara i usluga
Polje001 int64 // oslobođen sa pravom na odbitak (naknada)
Polje002 int64 // oslobođen bez prava na odbitak (naknada)
Polje003 int64 // opšta stopa — naknada bez PDV
Polje103 int64 // opšta stopa — obračunati PDV
Polje004 int64 // posebna stopa — naknada bez PDV
Polje104 int64 // posebna stopa — obračunati PDV
Polje005 int64 // zbir naknada (1+2+3+4)
Polje105 int64 // zbir izlaznog PDV (103+104)
// II. prethodni porez
Polje006 int64 // prethodni porez pri uvozu — naknada (ne prati se → 0)
Polje106 int64 // prethodni porez pri uvozu — PDV (ne prati se → 0)
Polje007 int64 // PDV nadoknada poljoprivredniku — naknada (ne prati se → 0)
Polje107 int64 // PDV nadoknada poljoprivredniku — PDV (ne prati se → 0)
Polje008 int64 // ostali prethodni porez — naknada
Polje108 int64 // ostali prethodni porez — PDV (odbitni)
Polje009 int64 // zbir naknada (6+7+8)
Polje109 int64 // zbir prethodnog poreza (106+107+108)
// III. poreska obaveza
Polje110 int64 // iznos PDV u periodu (105 109); >0 za uplatu, <0 za povraćaj
Povracaj bool // polje 11: true kada je 110 negativan (iznos za povraćaj)
}
// Polje110Apsolutno vraća iznos polja 110 bez predznaka (za prikaz povraćaja).
func (p PPPDV) Polje110Apsolutno() int64 {
if p.Polje110 < 0 {
return -p.Polje110
}
return p.Polje110
}
// MapirajPPPDV preslikava zbirove KIR i KPR na polja obrasca PPPDV (cele dinare).
// KPR je razdvojen: uvoz (JCI) ide u polja 006/106 (prethodni porez pri uvozu),
// domaća nabavka u 008/108 (ostali prethodni porez).
// ⚠ PDV nadoknada poljoprivredniku (007/107) se i dalje ne prati zasebno (ostaje 0).
func MapirajPPPDV(kir PdvKirSume, kprDomace, kprUvoz PdvKprSume) PPPDV {
uDinare := func(v float64) int64 { return int64(math.Round(v)) }
p := PPPDV{
Polje001: uDinare(kir.OslobodenSaPravom),
Polje002: uDinare(kir.OslobodenBezPrava),
Polje003: uDinare(kir.OsnovicaOpsta),
Polje103: uDinare(kir.PdvOpsta),
Polje004: uDinare(kir.OsnovicaPosebna),
Polje104: uDinare(kir.PdvPosebna),
Polje006: uDinare(kprUvoz.OsnovicaOpsta + kprUvoz.OsnovicaPosebna),
Polje106: uDinare(kprUvoz.PdvOpsta + kprUvoz.PdvPosebna),
Polje008: uDinare(kprDomace.OsnovicaOpsta + kprDomace.OsnovicaPosebna),
Polje108: uDinare(kprDomace.PdvOpsta + kprDomace.PdvPosebna),
}
// zbirovi se računaju iz zaokruženih polja da kolone na obrascu uvek zbiraju
p.Polje005 = p.Polje001 + p.Polje002 + p.Polje003 + p.Polje004
p.Polje105 = p.Polje103 + p.Polje104
p.Polje009 = p.Polje006 + p.Polje007 + p.Polje008
p.Polje109 = p.Polje106 + p.Polje107 + p.Polje108
p.Polje110 = p.Polje105 - p.Polje109
p.Povracaj = p.Polje110 < 0
return p
}
// ObracunajPdv računa obavezu PDV iz zbirova KIR i KPR za isti period.
// PdvBezOdbitka iz KPR se namerno NE računa u odbitni PDV — to je PDV za koji
// ne postoji pravo na odbitak prethodnog poreza.
func ObracunajPdv(kir PdvKirSume, kpr PdvKprSume) PdvObracun {
o := PdvObracun{
IzlazniPdvOpsta: kir.PdvOpsta,
IzlazniPdvPosebna: kir.PdvPosebna,
OdbitniPdvOpsta: kpr.PdvOpsta,
OdbitniPdvPosebna: kpr.PdvPosebna,
}
o.IzlazniPdvUkupno = o.IzlazniPdvOpsta + o.IzlazniPdvPosebna
o.OdbitniPdvUkupno = o.OdbitniPdvOpsta + o.OdbitniPdvPosebna
o.Obaveza = o.IzlazniPdvUkupno - o.OdbitniPdvUkupno
return o
}
+161
View File
@@ -0,0 +1,161 @@
package model
import (
"math"
"testing"
"time"
)
func blizu(a, b float64) bool { return math.Abs(a-b) < 0.01 }
func TestKirIzProdaje(t *testing.T) {
nalog := ProdajniNalog{ID: 5, BrojNaloga: "P-1", Datum: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)}
stavke := []StavkaProdaje{
// 20%: 2 × 120 = 240 (osnovica 200, PDV 40)
{Kolicina: 2, CenaPoKomadu: 120, PdvStopa: 20},
// 10%: 1 × 110 = 110 (osnovica 100, PDV 10)
{Kolicina: 1, CenaPoKomadu: 110, PdvStopa: 10},
// 0%: 1 × 50 = 50 (oslobođeno, bez PDV)
{Kolicina: 1, CenaPoKomadu: 50, PdvStopa: 0},
}
k := KirIzProdaje(nalog, stavke, "Kupac doo", "123456789", "Niš")
if k.Izvor != "prodaja" || k.IzvorID == nil || *k.IzvorID != 5 {
t.Errorf("izvor=%q izvor_id=%v, očekivano prodaja/5", k.Izvor, k.IzvorID)
}
if k.BrojDokumenta != "P-1" || k.KupacNaziv != "Kupac doo" || k.KupacPib != "123456789" {
t.Errorf("zaglavlje ne odgovara: %+v", k)
}
if !blizu(k.OsnovicaOpsta, 200) || !blizu(k.PdvOpsta, 40) {
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano 200/40", k.OsnovicaOpsta, k.PdvOpsta)
}
if !blizu(k.OsnovicaPosebna, 100) || !blizu(k.PdvPosebna, 10) {
t.Errorf("posebna: osnovica=%v pdv=%v, očekivano 100/10", k.OsnovicaPosebna, k.PdvPosebna)
}
if !blizu(k.OslobodenSaPravom, 50) {
t.Errorf("oslobođeno=%v, očekivano 50", k.OslobodenSaPravom)
}
if !blizu(k.Ukupno, 400) {
t.Errorf("ukupno=%v, očekivano 400 (240+110+50)", k.Ukupno)
}
}
func TestKprIzNabavke(t *testing.T) {
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
stavke := []NabavkaStavkaPdv{
{Osnovica: 200, PdvStopa: 20}, // PDV 40
{Osnovica: 100, PdvStopa: 10}, // PDV 10
{Osnovica: 50, PdvStopa: 0}, // oslobođena nabavka
}
k := KprIzNabavke(nabavka, "Dobavljač doo", "987654321", "Beograd", stavke)
if k.Izvor != "nabavka" || k.IzvorID == nil || *k.IzvorID != 3 {
t.Errorf("izvor=%q izvor_id=%v, očekivano nabavka/3", k.Izvor, k.IzvorID)
}
if k.BrojDokumenta != "NAB-3" || k.DobavljacNaziv != "Dobavljač doo" || k.DobavljacPib != "987654321" {
t.Errorf("zaglavlje ne odgovara: %+v", k)
}
if !blizu(k.OsnovicaOpsta, 200) || !blizu(k.PdvOpsta, 40) {
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano 200/40", k.OsnovicaOpsta, k.PdvOpsta)
}
if !blizu(k.OsnovicaPosebna, 100) || !blizu(k.PdvPosebna, 10) {
t.Errorf("posebna: osnovica=%v pdv=%v, očekivano 100/10", k.OsnovicaPosebna, k.PdvPosebna)
}
if !blizu(k.OslobodenNabavka, 50) {
t.Errorf("oslobođena nabavka=%v, očekivano 50", k.OslobodenNabavka)
}
if !blizu(k.Ukupno, 400) {
t.Errorf("ukupno=%v, očekivano 400 (240+110+50)", k.Ukupno)
}
}
func TestObracunajPdv(t *testing.T) {
// izlazni: 200 (opšta) + 50 (posebna) = 250; odbitni: 80 + 20 = 100; obaveza = 150
kir := PdvKirSume{PdvOpsta: 200, PdvPosebna: 50}
kpr := PdvKprSume{PdvOpsta: 80, PdvPosebna: 20, PdvBezOdbitka: 30}
o := ObracunajPdv(kir, kpr)
if !blizu(o.IzlazniPdvUkupno, 250) {
t.Errorf("izlazni=%v, očekivano 250", o.IzlazniPdvUkupno)
}
// PdvBezOdbitka (30) ne sme da uđe u odbitni PDV
if !blizu(o.OdbitniPdvUkupno, 100) {
t.Errorf("odbitni=%v, očekivano 100 (bez PdvBezOdbitka)", o.OdbitniPdvUkupno)
}
if !blizu(o.Obaveza, 150) || !o.ZaUplatu() {
t.Errorf("obaveza=%v ZaUplatu=%v, očekivano 150/true", o.Obaveza, o.ZaUplatu())
}
// kada je odbitni veći od izlaznog → negativna obaveza (povraćaj/prenos), ZaUplatu=false
o2 := ObracunajPdv(PdvKirSume{PdvOpsta: 40}, PdvKprSume{PdvOpsta: 100})
if !blizu(o2.Obaveza, -60) || o2.ZaUplatu() {
t.Errorf("obaveza=%v ZaUplatu=%v, očekivano -60/false", o2.Obaveza, o2.ZaUplatu())
}
}
func TestMapirajPPPDV(t *testing.T) {
kir := PdvKirSume{
OsnovicaOpsta: 1000.4, PdvOpsta: 200.08, // 003=1000, 103=200
OsnovicaPosebna: 500.5, PdvPosebna: 50.05, // 004=501 (round), 104=50
OslobodenSaPravom: 300.2, OslobodenBezPrava: 100.9, // 001=300, 002=101
}
kpr := PdvKprSume{
OsnovicaOpsta: 400, PdvOpsta: 80,
OsnovicaPosebna: 100, PdvPosebna: 10,
PdvBezOdbitka: 25, // ne sme da uđe nigde u PPPDV
}
p := MapirajPPPDV(kir, kpr, PdvKprSume{})
if p.Polje001 != 300 || p.Polje002 != 101 || p.Polje003 != 1000 || p.Polje103 != 200 {
t.Errorf("I deo (001/002/003/103) = %d/%d/%d/%d", p.Polje001, p.Polje002, p.Polje003, p.Polje103)
}
if p.Polje004 != 501 || p.Polje104 != 50 {
t.Errorf("posebna 004/104 = %d/%d, očekivano 501/50", p.Polje004, p.Polje104)
}
// 005 = 300+101+1000+501 = 1902; 105 = 200+50 = 250
if p.Polje005 != 1902 || p.Polje105 != 250 {
t.Errorf("zbir 005/105 = %d/%d, očekivano 1902/250", p.Polje005, p.Polje105)
}
// u ovom scenariju nema uvoza (prazan kprUvoz) ni poljoprivrednika → 006/106/007/107 = 0
if p.Polje006 != 0 || p.Polje106 != 0 || p.Polje007 != 0 || p.Polje107 != 0 {
t.Errorf("006/106/007/107 moraju biti 0")
}
// 008 = 400+100 = 500; 108 = 80+10 = 90 (PdvBezOdbitka 25 izostavljen)
if p.Polje008 != 500 || p.Polje108 != 90 {
t.Errorf("ostali prethodni 008/108 = %d/%d, očekivano 500/90", p.Polje008, p.Polje108)
}
if p.Polje009 != 500 || p.Polje109 != 90 {
t.Errorf("zbir prethodnog 009/109 = %d/%d, očekivano 500/90", p.Polje009, p.Polje109)
}
// 110 = 250 90 = 160 (za uplatu)
if p.Polje110 != 160 || p.Povracaj {
t.Errorf("110=%d Povracaj=%v, očekivano 160/false", p.Polje110, p.Povracaj)
}
// scenario povraćaja: izlazni < odbitni → 110 negativan, Povracaj=true
p2 := MapirajPPPDV(PdvKirSume{PdvOpsta: 30}, PdvKprSume{PdvOpsta: 90}, PdvKprSume{})
if p2.Polje110 != -60 || !p2.Povracaj || p2.Polje110Apsolutno() != 60 {
t.Errorf("povraćaj: 110=%d Povracaj=%v abs=%d, očekivano -60/true/60", p2.Polje110, p2.Povracaj, p2.Polje110Apsolutno())
}
// scenario uvoza: uvozni KPR ide u 006/106, domaći u 008/108
p3 := MapirajPPPDV(
PdvKirSume{},
PdvKprSume{OsnovicaOpsta: 1000, PdvOpsta: 200}, // domaće → 008/108
PdvKprSume{OsnovicaOpsta: 500, PdvOpsta: 100, OsnovicaPosebna: 50, PdvPosebna: 5}, // uvoz → 006/106
)
if p3.Polje006 != 550 || p3.Polje106 != 105 {
t.Errorf("uvoz 006/106 = %d/%d, očekivano 550/105", p3.Polje006, p3.Polje106)
}
if p3.Polje008 != 1000 || p3.Polje108 != 200 {
t.Errorf("domaće 008/108 = %d/%d, očekivano 1000/200", p3.Polje008, p3.Polje108)
}
// 009 = 006+007+008 = 550+0+1000 = 1550; 109 = 106+107+108 = 105+0+200 = 305
if p3.Polje009 != 1550 || p3.Polje109 != 305 {
t.Errorf("zbir prethodnog 009/109 = %d/%d, očekivano 1550/305", p3.Polje009, p3.Polje109)
}
}
+15
View File
@@ -0,0 +1,15 @@
package model
import "time"
// PdvStopa je jedna stavka u šifarniku PDV stopa (Faza 1 knjigovodstvenog modula).
// Stope su podatak, ne hardkod — nova ili izmenjena stopa bez diranja koda.
type PdvStopa struct {
ID int64
Naziv string // npr. "Opšta stopa"
Stopa float64 // procenat, npr. 20.0
Oznaka string // "opsta" | "posebna" | "oslobodjeno"
Aktivna bool // false = arhivirana (ne nudi se u listama, ali stari zapisi ostaju ispravni)
Redosled int // redosled prikaza
DatumUnosa time.Time
}
+1
View File
@@ -44,6 +44,7 @@ type PodaciStranice struct {
CsrfToken string // CSRF zaštitni token za forme CsrfToken string // CSRF zaštitni token za forme
AssetV string // verzija statičkih fajlova (cache-busting za CSS/JS) AssetV string // verzija statičkih fajlova (cache-busting za CSS/JS)
Dozvole map[string]bool // mapa akcija → dozvoljeno/nije Dozvole map[string]bool // mapa akcija → dozvoljeno/nije
Moduli map[string]bool // mapa zakonskih modula → uključen za firmu (profil firme)
Flash *FlashPoruka // jednokratna poruka nakon redirecta Flash *FlashPoruka // jednokratna poruka nakon redirecta
// app pozadina — popunjava se iz podešavanja za sve stranice // app pozadina — popunjava se iz podešavanja za sve stranice
AppPozadina string AppPozadina string
+21
View File
@@ -0,0 +1,21 @@
-- Šifarnik PDV stopa (Faza 1 knjigovodstvenog modula).
-- Stope se čuvaju kao podatak, ne hardkod — nova ili izmenjena stopa bez diranja koda.
-- Početne vrednosti po važećem zakonu (jun 2026): opšta 20%, posebna 10%, oslobođeno 0%.
CREATE TABLE IF NOT EXISTS pdv_stope (
id INTEGER PRIMARY KEY AUTOINCREMENT,
naziv TEXT NOT NULL, -- npr. "Opšta stopa"
stopa REAL NOT NULL, -- procenat, npr. 20.0
oznaka TEXT NOT NULL, -- "opsta" | "posebna" | "oslobodjeno"
aktivna INTEGER NOT NULL DEFAULT 1, -- 1 = u upotrebi, 0 = arhivirana
redosled INTEGER NOT NULL DEFAULT 0, -- redosled prikaza u listama
datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Seed početnih stopa samo ako je tabela prazna (idempotentno, ne gazi ručne izmene).
INSERT INTO pdv_stope (naziv, stopa, oznaka, redosled)
SELECT naziv, stopa, oznaka, redosled FROM (
SELECT 'Opšta stopa' AS naziv, 20.0 AS stopa, 'opsta' AS oznaka, 1 AS redosled
UNION ALL SELECT 'Posebna stopa', 10.0, 'posebna', 2
UNION ALL SELECT 'Oslobođeno (0%)', 0.0, 'oslobodjeno', 3
)
WHERE NOT EXISTS (SELECT 1 FROM pdv_stope);
+46
View File
@@ -0,0 +1,46 @@
-- PDV evidencija (Faza 2a knjigovodstvenog modula): knjiga izdatih računa (KIR)
-- i knjiga primljenih računa (KPR). Iznosi se vode po VRSTI stope (opšta/posebna),
-- ne po procentu — promena stope zakonom ne razbija kolone.
-- Napomena: ovo je osnovna knjiga; pun POPDV obrazac (uvoz, avansi, poljoprivreda)
-- dolazi u kasnijoj iteraciji.
-- Knjiga izdatih računa (izlazni PDV).
CREATE TABLE IF NOT EXISTS pdv_kir (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datum_prometa DATE NOT NULL, -- datum prometa / izdavanja računa
datum_knjizenja DATE NOT NULL, -- datum knjiženja u KIR
broj_dokumenta TEXT NOT NULL, -- broj računa ili drugog dokumenta
kupac_naziv TEXT NOT NULL,
kupac_pib TEXT, -- PIB ili JMBG kupca
kupac_mesto TEXT,
osnovica_opsta REAL NOT NULL DEFAULT 0, -- osnovica oporeziva opštom stopom
pdv_opsta REAL NOT NULL DEFAULT 0, -- obračunati PDV po opštoj stopi
osnovica_posebna REAL NOT NULL DEFAULT 0, -- osnovica oporeziva posebnom stopom
pdv_posebna REAL NOT NULL DEFAULT 0, -- obračunati PDV po posebnoj stopi
osloboden_sa_pravom REAL NOT NULL DEFAULT 0, -- oslobođen promet sa pravom na odbitak
osloboden_bez_prava REAL NOT NULL DEFAULT 0, -- oslobođen promet bez prava na odbitak
ukupno REAL NOT NULL DEFAULT 0, -- ukupna naknada sa PDV
napomena TEXT,
datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Knjiga primljenih računa (ulazni PDV).
CREATE TABLE IF NOT EXISTS pdv_kpr (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datum_prometa DATE NOT NULL, -- datum prometa / prijema računa
datum_knjizenja DATE NOT NULL, -- datum knjiženja u KPR
datum_placanja DATE, -- datum plaćanja (može biti prazan)
broj_dokumenta TEXT NOT NULL,
dobavljac_naziv TEXT NOT NULL,
dobavljac_pib TEXT,
dobavljac_mesto TEXT,
osnovica_opsta REAL NOT NULL DEFAULT 0,
pdv_opsta REAL NOT NULL DEFAULT 0, -- PDV koji se može odbiti (opšta)
osnovica_posebna REAL NOT NULL DEFAULT 0,
pdv_posebna REAL NOT NULL DEFAULT 0, -- PDV koji se može odbiti (posebna)
pdv_bez_odbitka REAL NOT NULL DEFAULT 0, -- PDV bez prava na odbitak
osloboden_nabavka REAL NOT NULL DEFAULT 0, -- nabavka bez PDV / oslobođena
ukupno REAL NOT NULL DEFAULT 0,
napomena TEXT,
datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+3
View File
@@ -0,0 +1,3 @@
-- Dodaje mesto/grad u klijente. Koristi se pri izboru klijenta u KIR formi
-- (popunjava mesto kupca), a korisno je i samo po sebi za adresu klijenta.
ALTER TABLE klijenti ADD COLUMN mesto TEXT;
+4
View File
@@ -0,0 +1,4 @@
-- Dodaje PIB i mesto/grad u dobavljače. PIB dobavljača je obavezan podatak za
-- knjigu primljenih računa (KPR) i POPDV (evidencija prethodnog/odbitnog PDV-a).
ALTER TABLE dobavljaci ADD COLUMN pib TEXT;
ALTER TABLE dobavljaci ADD COLUMN mesto TEXT;
+8
View File
@@ -0,0 +1,8 @@
-- Veza KIR/KPR zapisa sa izvorom (Faza 2b — automatsko punjenje).
-- izvor: 'rucno' (ručni unos), 'prodaja', 'nabavka'; izvor_id = id naloga/nabavke.
-- Postojeći zapisi dobijaju 'rucno' (DEFAULT). Omogućava brisanje vezanog zapisa
-- pri stornu/brisanju izvora i sprečava duplikate.
ALTER TABLE pdv_kir ADD COLUMN izvor TEXT NOT NULL DEFAULT 'rucno';
ALTER TABLE pdv_kir ADD COLUMN izvor_id INTEGER;
ALTER TABLE pdv_kpr ADD COLUMN izvor TEXT NOT NULL DEFAULT 'rucno';
ALTER TABLE pdv_kpr ADD COLUMN izvor_id INTEGER;
+17
View File
@@ -0,0 +1,17 @@
-- Nivelacije (Faza A kalkulacije/nivelacije): trag svake promene prodajne cene artikla.
-- Razlika (nova stara) se izvodi u programu, ne čuva se. Izvor razlikuje način promene:
-- 'rucno' (posebna akcija sa razlogom), 'izmena' (auto pri izmeni artikla), 'kalkulacija' (kasnije).
CREATE TABLE IF NOT EXISTS nivelacije (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artikal_id INTEGER NOT NULL REFERENCES artikli(id) ON DELETE CASCADE,
stara_cena REAL NOT NULL,
nova_cena REAL NOT NULL,
razlog TEXT,
izvor TEXT NOT NULL DEFAULT 'rucno',
korisnik_id INTEGER REFERENCES korisnici(id) ON DELETE SET NULL,
datum DATE NOT NULL, -- poslovni datum promene
datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_nivelacije_artikal ON nivelacije(artikal_id);
CREATE INDEX IF NOT EXISTS idx_nivelacije_datum ON nivelacije(datum);
@@ -0,0 +1,6 @@
-- Dodaje podrazumevanu maržu (%) na artikle i kategorije za kalkulaciju
-- prodajne cene pri nabavci. Kolone su NULL kada marža nije postavljena —
-- tako se „nije postavljeno" razlikuje od svesno unete marže 0%.
-- Redosled izvođenja marže: artikal.marza → kategorija.marza → globalna kalkulacija_marza.
ALTER TABLE artikli ADD COLUMN marza REAL;
ALTER TABLE kategorije ADD COLUMN marza REAL;
@@ -0,0 +1,17 @@
-- Zavisni troškovi nabavke (carina, prevoz, špedicija…) i metod njihove raspodele.
-- Troškovi se raspodeljuju na stavke i ulaze u kalkulativnu nabavnu cenu, iz koje
-- se računa prodajna. Slobodne stavke (naziv + iznos), više redova po nabavci.
-- metod raspodele po nabavci: 'vrednost' (po nabavnoj vrednosti stavke) ili 'kolicina'.
-- NULL/prazno = nema zavisnih troškova (kalkulacija kao u Fazi B).
ALTER TABLE nabavke ADD COLUMN metod_raspodele TEXT;
-- pojedinačne stavke zavisnih troškova; brišu se zajedno sa nabavkom (CASCADE)
CREATE TABLE IF NOT EXISTS nabavka_troskovi (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nabavka_id INTEGER NOT NULL REFERENCES nabavke(id) ON DELETE CASCADE,
naziv TEXT NOT NULL,
iznos REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_nabavka_troskovi_nabavka ON nabavka_troskovi(nabavka_id);
+6
View File
@@ -0,0 +1,6 @@
-- Zastavica „uvoz" na zapisima knjige primljenih računa (KPR).
-- Uvoz se unosi RUČNO sa JCI (osnovica i PDV iz carinskog dokumenta drže postojeće
-- kolone osnovica_opsta / pdv_opsta). Zastavica služi da se uvozni PDV u PPPDV obrascu
-- rutira u polja 006/106 (prethodni porez pri uvozu) umesto 008/108 (ostali prethodni).
-- 0 = domaća nabavka (podrazumevano), 1 = uvoz.
ALTER TABLE pdv_kpr ADD COLUMN uvoz INTEGER NOT NULL DEFAULT 0;
+1 -1
View File
@@ -327,7 +327,7 @@ body {
} }
.kartica:hover { .kartica:hover {
transform: translateY(-4px); transform: scale(1.02);
box-shadow: var(--senka); box-shadow: var(--senka);
} }
+159 -2
View File
@@ -186,27 +186,118 @@ document.addEventListener('alpine:init', () => {
// forma za nabavku // forma za nabavku
Alpine.data('nabavkaForma', () => ({ Alpine.data('nabavkaForma', () => ({
stavke: [{artikal_id: '', kolicina: 1, cena: 0}], stavke: [{artikal_id: '', kolicina: 1, cena: 0, marza: 0, prodajna: 0}],
artikliOpcije: [], artikliOpcije: [],
marzaDefault: 0,
troskovi: [], // zavisni troškovi {naziv, iznos}
metodRaspodele: 'vrednost', // 'vrednost' ili 'kolicina'
pdvObveznik: true, // da li firma obračunava PDV (utiče na prodajnu cenu)
isMobile: false, isMobile: false,
modal: false, modal: false,
modalUcitavanje: false, modalUcitavanje: false,
modalGreska: '', modalGreska: '',
modalNaziv: '', modalNaziv: '',
modalKategorijaID: '', modalKategorijaID: '',
modalOpis: '',
modalKolicina: '',
modalKolicinaMin: '',
modalCena: '', modalCena: '',
modalLokacija: '',
modalNapomena: '',
modalDob: false,
modalDobUcitavanje: false,
modalDobGreska: '',
modalDobNaziv: '',
modalDobKontakt: '',
modalDobTelefon: '',
modalDobEmail: '',
modalDobPib: '',
modalDobMesto: '',
modalDobNapomena: '',
init() { init() {
this.artikliOpcije = window._ntechArtikli || [] this.artikliOpcije = window._ntechArtikli || []
this.marzaDefault = parseFloat(window._ntechMarza) || 0
this.pdvObveznik = window._ntechPdvObveznik === true
this.stavke.forEach(s => { s.marza = this.marzaDefault })
this.isMobile = window.matchMedia('(max-width: 768px)').matches this.isMobile = window.matchMedia('(max-width: 768px)').matches
window.matchMedia('(max-width: 768px)').addEventListener('change', e => { window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
this.isMobile = e.matches this.isMobile = e.matches
}) })
}, },
dodajStavku() { dodajStavku() {
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0}) this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, marza: this.marzaDefault, prodajna: 0})
this.preracunajSve()
},
// 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) {
if (!this.pdvObveznik) return 0
const a = this.artikliOpcije.find(x => String(x.id) === String(artikalId))
return a ? (parseFloat(a.pdv_stopa) || 0) : 0
},
// pri izboru artikla predloži maržu: artikal → kategorija → globalna, pa izračunaj prodajnu
izaberiArtikal(s) {
const a = this.artikliOpcije.find(x => String(x.id) === String(s.artikal_id))
if (a) {
if (a.marza != null) s.marza = a.marza
else if (a.kategorija_marza != null) s.marza = a.kategorija_marza
else s.marza = this.marzaDefault
}
this.izracunajProdajnu(s)
},
// ukupan zavisni trošak nabavke
ukupanTrosak() {
return this.troskovi.reduce((z, t) => z + (parseFloat(t.iznos) || 0), 0)
},
// osnovica raspodele po izabranom metodu (zbir po svim stavkama)
osnovicaRaspodele() {
return this.stavke.reduce((z, s) => {
const kol = parseFloat(s.kolicina) || 0
return z + (this.metodRaspodele === 'kolicina' ? kol : kol * (parseFloat(s.cena) || 0))
}, 0)
},
// kalkulativna nabavna cena po komadu (fakturna + raspodeljeni trošak) — isto kao server
kalkNabavna(s) {
const cena = parseFloat(s.cena) || 0
const kol = parseFloat(s.kolicina) || 0
const trosak = this.ukupanTrosak()
const osn = this.osnovicaRaspodele()
if (trosak <= 0 || osn <= 0 || kol <= 0) return cena
const baza = this.metodRaspodele === 'kolicina' ? kol : kol * cena
const trosakPoKomadu = (trosak * (baza / osn)) / kol
return Math.round((cena + trosakPoKomadu) * 100) / 100
},
// prodajna (sa PDV) = kalkulativna nabavna × (1 + marža/100) × (1 + pdvStopa/100)
izracunajProdajnu(s) {
const nabavna = this.kalkNabavna(s)
const marza = parseFloat(s.marza) || 0
const pdv = this.pdvStopa(s.artikal_id)
s.prodajna = Math.round(nabavna * (1 + marza / 100) * (1 + pdv / 100) * 100) / 100
},
// obrnuti smer: iz ručno unete prodajne cene izvedi maržu (%)
// marža = (prodajna / (nabavna × (1 + pdv/100)) 1) × 100
izracunajMarzu(s) {
const nabavna = this.kalkNabavna(s)
const pdv = this.pdvStopa(s.artikal_id)
const osnovica = nabavna * (1 + pdv / 100)
if (osnovica <= 0) return // bez nabavne cene marža se ne može izvesti
const prodajna = parseFloat(s.prodajna) || 0
s.marza = Math.round(((prodajna / osnovica) - 1) * 100 * 100) / 100
},
// raspodela zavisi od svih stavki — promena troška/metoda/količine/cene preračunava sve
preracunajSve() {
this.stavke.forEach(s => this.izracunajProdajnu(s))
},
dodajTrosak() {
this.troskovi.push({naziv: '', iznos: 0})
},
ukloniTrosak(i) {
this.troskovi.splice(i, 1)
this.preracunajSve()
}, },
ukloniStavku(i) { ukloniStavku(i) {
if (this.stavke.length > 1) this.stavke.splice(i, 1) if (this.stavke.length > 1) this.stavke.splice(i, 1)
this.preracunajSve()
}, },
ukupnoStavke(s) { ukupnoStavke(s) {
return (parseFloat(s.kolicina) * parseFloat(s.cena) || 0).toFixed(2) return (parseFloat(s.kolicina) * parseFloat(s.cena) || 0).toFixed(2)
@@ -219,7 +310,12 @@ document.addEventListener('alpine:init', () => {
this.modalGreska = '' this.modalGreska = ''
this.modalNaziv = '' this.modalNaziv = ''
this.modalKategorijaID = '' this.modalKategorijaID = ''
this.modalOpis = ''
this.modalKolicina = ''
this.modalKolicinaMin = ''
this.modalCena = '' this.modalCena = ''
this.modalLokacija = ''
this.modalNapomena = ''
this.$nextTick(() => this.$refs.modalNazivInput && this.$refs.modalNazivInput.focus()) this.$nextTick(() => this.$refs.modalNazivInput && this.$refs.modalNazivInput.focus())
}, },
zatvoriModal() { zatvoriModal() {
@@ -235,7 +331,12 @@ document.addEventListener('alpine:init', () => {
const params = new URLSearchParams() const params = new URLSearchParams()
params.append('naziv', this.modalNaziv.trim()) params.append('naziv', this.modalNaziv.trim())
if (this.modalKategorijaID) params.append('kategorija_id', this.modalKategorijaID) if (this.modalKategorijaID) params.append('kategorija_id', this.modalKategorijaID)
params.append('opis', this.modalOpis.trim())
if (this.modalKolicina) params.append('kolicina', this.modalKolicina)
if (this.modalKolicinaMin) params.append('kolicina_min', this.modalKolicinaMin)
if (this.modalCena) params.append('prodajna_cena', this.modalCena) if (this.modalCena) params.append('prodajna_cena', this.modalCena)
params.append('lokacija', this.modalLokacija.trim())
params.append('napomena', this.modalNapomena.trim())
params.append('_csrf', document.querySelector('meta[name=csrf-token]')?.content || '') params.append('_csrf', document.querySelector('meta[name=csrf-token]')?.content || '')
try { try {
const odgovor = await fetch('/magacin/novi', { const odgovor = await fetch('/magacin/novi', {
@@ -255,6 +356,62 @@ document.addEventListener('alpine:init', () => {
} finally { } finally {
this.modalUcitavanje = false this.modalUcitavanje = false
} }
},
otvoriModalDobavljac() {
this.modalDob = true
this.modalDobGreska = ''
this.modalDobNaziv = ''
this.modalDobKontakt = ''
this.modalDobTelefon = ''
this.modalDobEmail = ''
this.modalDobPib = ''
this.modalDobMesto = ''
this.modalDobNapomena = ''
this.$nextTick(() => this.$refs.modalDobNazivInput && this.$refs.modalDobNazivInput.focus())
},
zatvoriModalDobavljac() {
this.modalDob = false
},
async sacuvajDobavljaca() {
if (!this.modalDobNaziv.trim()) {
this.modalDobGreska = 'Naziv dobavljača je obavezan.'
return
}
this.modalDobUcitavanje = true
this.modalDobGreska = ''
const params = new URLSearchParams()
params.append('naziv', this.modalDobNaziv.trim())
params.append('kontakt_osoba', this.modalDobKontakt.trim())
params.append('telefon', this.modalDobTelefon.trim())
params.append('email', this.modalDobEmail.trim())
params.append('pib', this.modalDobPib.trim())
params.append('mesto', this.modalDobMesto.trim())
params.append('napomena', this.modalDobNapomena.trim())
params.append('_csrf', document.querySelector('meta[name=csrf-token]')?.content || '')
try {
const odgovor = await fetch('/dobavljaci/novi', {
method: 'POST',
headers: {'X-Requested-With': 'fetch', 'Content-Type': 'application/x-www-form-urlencoded'},
body: params
})
if (!odgovor.ok) {
const greska = await odgovor.json().catch(() => null)
this.modalDobGreska = (greska && greska.greska) || 'Greška pri čuvanju dobavljača. Pokušajte ponovo.'
return
}
const novi = await odgovor.json()
// dodaj novog dobavljača u padajuću listu i odmah ga izaberi
const opcija = document.createElement('option')
opcija.value = novi.id
opcija.textContent = novi.naziv
this.$refs.selDobavljac.appendChild(opcija)
this.$refs.selDobavljac.value = novi.id
this.zatvoriModalDobavljac()
} catch {
this.modalDobGreska = 'Greška pri komunikaciji sa serverom.'
} finally {
this.modalDobUcitavanje = false
}
} }
})) }))
+56 -3
View File
@@ -93,6 +93,54 @@
</a> </a>
{{end}} {{end}}
{{/* Knjigovodstvo — grupa: KIR/KPR/Obračun (modul „pdv") + Nivelacije (dozvola izmene cena) */}}
{{$pdvVid := and (index .Moduli "pdv") (index .Dozvole "pdv.pregled")}}
{{$knjVid := or $pdvVid (index .Dozvole "artikal.izmeni")}}
{{if $knjVid}}
{{$knjAktivan := or (eq .Stranica "nivelacije") (eq .Stranica "pdv-kir") (eq .Stranica "pdv-kpr") (eq .Stranica "pdv-obracun")}}
<div>
<button type="button" data-podmeni-dugme
class="nav-stavka {{if $knjAktivan}}aktivan{{end}}"
style="width:100%;background:none;border:none;cursor:pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<span>Knjigovodstvo</span>
<span class="nav-strelica">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="transition:transform 0.2s;transform:{{if $knjAktivan}}rotate(180deg){{else}}rotate(0deg){{end}}">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
<span class="nav-tooltip">Knjigovodstvo</span>
</button>
<div class="nav-podmeni {{if $knjAktivan}}otvoren{{end}}">
{{if index .Dozvole "artikal.izmeni"}}
<a href="/nivelacije" class="nav-stavka nav-podstavka {{if eq .Stranica "nivelacije"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>
<span>Nivelacije</span>
<span class="nav-tooltip">Nivelacije — promene cena</span>
</a>
{{end}}
{{if $pdvVid}}
<a href="/pdv/kir" class="nav-stavka nav-podstavka {{if eq .Stranica "pdv-kir"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span>KIR</span>
<span class="nav-tooltip">KIR — knjiga izdatih računa</span>
</a>
<a href="/pdv/kpr" class="nav-stavka nav-podstavka {{if eq .Stranica "pdv-kpr"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span>KPR</span>
<span class="nav-tooltip">KPR — knjiga primljenih računa</span>
</a>
<a href="/pdv/obracun" class="nav-stavka nav-podstavka {{if eq .Stranica "pdv-obracun"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="10" x2="10" y2="10"/><line x1="14" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="10" y2="14"/><line x1="14" y1="14" x2="16" y2="14"/><line x1="8" y1="18" x2="16" y2="18"/></svg>
<span>Obračun</span>
<span class="nav-tooltip">PDV obračun za period</span>
</a>
{{end}}
</div>
</div>
{{end}}
<div class="nav-separator"></div> <div class="nav-separator"></div>
<div class="nav-oznaka">Nalog</div> <div class="nav-oznaka">Nalog</div>
@@ -147,19 +195,19 @@
{{if index .Dozvole "podesavanja.pregled"}} {{if index .Dozvole "podesavanja.pregled"}}
<div> <div>
<button type="button" data-podmeni-dugme <button type="button" data-podmeni-dugme
class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "dozvole")}}aktivan{{end}}" class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "dozvole")}}aktivan{{end}}"
style="width:100%;background:none;border:none;cursor:pointer;"> style="width:100%;background:none;border:none;cursor:pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>Podešavanja</span> <span>Podešavanja</span>
<span class="nav-strelica"> <span class="nav-strelica">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}"> style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}">
<polyline points="6 9 12 15 18 9"/> <polyline points="6 9 12 15 18 9"/>
</svg> </svg>
</span> </span>
<span class="nav-tooltip">Podešavanja</span> <span class="nav-tooltip">Podešavanja</span>
</button> </button>
<div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "dozvole")}}otvoren{{end}}"> <div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "dozvole")}}otvoren{{end}}">
<a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}"> <a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>
<span>Opšte</span> <span>Opšte</span>
@@ -175,6 +223,11 @@
<span>Sistem</span> <span>Sistem</span>
<span class="nav-tooltip">Sistem</span> <span class="nav-tooltip">Sistem</span>
</a> </a>
<a href="/admin/podesavanja/kalkulacija-pdv" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-kalkulacija-pdv"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></svg>
<span>Kalkulacija i PDV</span>
<span class="nav-tooltip">Kalkulacija i PDV</span>
</a>
{{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}} {{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}}
<a href="/admin/dozvole" class="nav-stavka nav-podstavka {{if eq .Stranica "dozvole"}}aktivan{{end}}"> <a href="/admin/dozvole" class="nav-stavka nav-podstavka {{if eq .Stranica "dozvole"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
@@ -59,6 +59,20 @@
style="width:100%;"> style="width:100%;">
</div> </div>
<!-- PIB i mesto (za KPR / POPDV) -->
<div class="forma-grid-2">
<div>
<label class="polje-labela">PIB</label>
<input type="text" name="pib" value="{{.Dobavljac.PIB}}"
placeholder="npr. 123456789" style="width:100%;">
</div>
<div>
<label class="polje-labela">Mesto / grad</label>
<input type="text" name="mesto" value="{{.Dobavljac.Mesto}}"
placeholder="npr. Beograd" style="width:100%;">
</div>
</div>
<!-- napomena --> <!-- napomena -->
<div> <div>
<label class="polje-labela">Napomena</label> <label class="polje-labela">Napomena</label>
+37
View File
@@ -52,6 +52,11 @@
<input type="text" name="opis" placeholder="Kratak opis kategorije..." <input type="text" name="opis" placeholder="Kratak opis kategorije..."
style="width:100%;"> style="width:100%;">
</div> </div>
<div>
<label class="polje-labela">Marža (%)</label>
<input type="number" name="marza" min="0" step="0.01"
placeholder="prazno = globalna marža" style="width:100%;">
</div>
<div style="display:flex;justify-content:flex-end;"> <div style="display:flex;justify-content:flex-end;">
<button type="submit" <button type="submit"
style="padding:8px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;"> style="padding:8px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">
@@ -76,6 +81,38 @@
<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}}
</div> </div>
{{if .Marza}}
<span style="font-size:12px;color:var(--tekst-sporedni);white-space:nowrap;">marža {{.Marza}}%</span>
{{end}}
{{if index $.Dozvole "kategorija.izmeni"}}
<button type="button" class="btn-primarno-malo" onclick="this.nextElementSibling.showModal()">Izmeni</button>
{{/* nativni modal — isti obrazac kao u magacinu (top layer, centriran) */}}
<dialog id="kat-{{.ID}}" class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Izmeni kategoriju</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<form method="POST" action="/magacin/kategorije/izmeni/{{.ID}}" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
<div>
<label class="polje-labela">Naziv <span style="color:#dc2626;">*</span></label>
<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">Opis</label>
<input type="text" name="opis" value="{{.Opis}}"
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">Marža (%)</label>
<input type="number" name="marza" min="0" step="0.01" value="{{if .Marza}}{{.Marza}}{{end}}"
placeholder="prazno = globalna marža"
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>
<button type="submit" class="btn-primarno" style="align-self:flex-end;">Sačuvaj</button>
</form>
</dialog>
{{end}}
{{if index $.Dozvole "kategorija.obrisi"}} {{if index $.Dozvole "kategorija.obrisi"}}
<a href="/magacin/kategorije/obrisi/{{.ID}}" class="btn-obrisi-malo" <a href="/magacin/kategorije/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovu kategoriju?"> data-potvrda="Da li ste sigurni da želite da obrišete ovu kategoriju?">
@@ -117,6 +117,11 @@
<input type="text" name="email" value="{{.Klijent.Email}}" <input type="text" name="email" value="{{.Klijent.Email}}"
placeholder="npr. marko@example.com"> placeholder="npr. marko@example.com">
</div> </div>
<div>
<label class="polje-labela">Mesto / grad</label>
<input type="text" name="mesto" value="{{.Klijent.Mesto}}"
placeholder="npr. Niš">
</div>
</div> </div>
</div> </div>
+29 -1
View File
@@ -83,6 +83,7 @@
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo"> <a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">
Izmeni Izmeni
</a> </a>
{{template "promeniCenuMeni" (dict "ID" .ID "Cena" .ProdajnaCena)}}
{{end}} {{end}}
{{if index $.Dozvole "artikal.premesti"}}{{if $.Kategorije}} {{if index $.Dozvole "artikal.premesti"}}{{if $.Kategorije}}
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "tab")}} {{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "tab")}}
@@ -112,7 +113,7 @@
<div class="magacin-kartice"> <div class="magacin-kartice">
{{range .Artikli}} {{range .Artikli}}
<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;"> <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> <div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</div>
{{if .KategorijaNaziv}} {{if .KategorijaNaziv}}
@@ -122,6 +123,7 @@
<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;">
{{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)}}
{{end}} {{end}}
{{if index $.Dozvole "artikal.premesti"}}{{if $.Kategorije}} {{if index $.Dozvole "artikal.premesti"}}{{if $.Kategorije}}
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}} {{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}}
@@ -179,3 +181,29 @@
</form> </form>
</dialog> </dialog>
{{end}} {{end}}
{{define "promeniCenuMeni"}}
<button type="button" class="btn-primarno-malo" style="align-self:center;"
onclick="this.nextElementSibling.showModal()">Promeni cenu</button>
{{/* nativni modal — isti obrazac kao premesti (top layer, centriran) */}}
<dialog id="cena-{{.ID}}" class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Promeni prodajnu cenu</h3>
<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>
<label class="polje-labela">Nova cena (din)</label>
<input type="number" name="nova_cena" min="0" step="0.01" value="{{printf "%.2f" .Cena}}" 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">Razlog (opciono)</label>
<input type="text" name="razlog" placeholder="npr. promena nabavne cene"
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>
<button type="submit" class="btn-primarno" style="align-self:flex-end;">Sačuvaj cenu</button>
</form>
</dialog>
{{end}}
@@ -71,6 +71,13 @@
<input type="number" name="prodajna_cena" value="{{.Artikal.ProdajnaCena}}" min="0" step="0.01" style="width:100%;"> <input type="number" name="prodajna_cena" value="{{.Artikal.ProdajnaCena}}" min="0" step="0.01" style="width:100%;">
</div> </div>
<!-- marža za kalkulaciju; prazno = nasleđuje maržu kategorije ili globalnu -->
<div>
<label class="polje-labela">Marža (%)</label>
<input type="number" name="marza" value="{{if .Artikal.Marza}}{{.Artikal.Marza}}{{end}}" min="0" step="0.01" style="width:100%;"
placeholder="prazno = po kategoriji / globalna">
</div>
<!-- lokacija --> <!-- lokacija -->
<div> <div>
<label class="polje-labela">Lokacija u magacinu</label> <label class="polje-labela">Lokacija u magacinu</label>
@@ -122,6 +122,30 @@
</div> </div>
</div> </div>
<!-- zavisni troškovi -->
{{if .Troskovi}}
<div class="kartica detalji-kartica animiraj" style="padding:0;overflow:hidden;">
<div style="padding:14px 16px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Zavisni troškovi</span>
<span class="pomocni-tekst">
Raspodela: {{if eq .Nabavka.MetodRaspodele "kolicina"}}po količini{{else}}po vrednosti stavke{{end}}
</span>
</div>
<div style="padding:8px 16px;">
{{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>
</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>
</div>
</div>
</div>
{{end}}
<!-- zona za brisanje --> <!-- zona za brisanje -->
<div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;"> <div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;"> <div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
+189 -9
View File
@@ -8,13 +8,15 @@
.forma-kartica:nth-child(1) { animation-delay: 0.04s; } .forma-kartica:nth-child(1) { animation-delay: 0.04s; }
.forma-kartica:nth-child(2) { animation-delay: 0.12s; } .forma-kartica:nth-child(2) { animation-delay: 0.12s; }
.modal-sadrzaj { animation: modalIn 0.25s ease forwards; } .modal-sadrzaj { animation: modalIn 0.25s ease forwards; }
/* centriranje preko klase (ne inline) — x-show menja inline display, pa bi obrisao flex i modal bi pao u ćošak */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; }
</style> </style>
{{end}} {{end}}
{{define "sadrzaj"}} {{define "sadrzaj"}}
<!-- lista artikala kao JSON — bezbedno serijalizovana na serveru --> <!-- lista artikala kao JSON — bezbedno serijalizovana na serveru -->
<script>var _ntechArtikli = {{.ArtikliJSON}};</script> <script>var _ntechArtikli = {{.ArtikliJSON}}; var _ntechMarza = {{.Marza}}; var _ntechPdvObveznik = {{.PdvObveznik}};</script>
<div style="width:100%;" x-data="nabavkaForma"> <div style="width:100%;" x-data="nabavkaForma">
@@ -37,8 +39,15 @@
</div> </div>
<div class="kolona" style="gap:14px;"> <div class="kolona" style="gap:14px;">
<div> <div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<label class="polje-labela">Dobavljač</label> <label class="polje-labela">Dobavljač</label>
<select name="dobavljac_id" style="width:100%;"> <button type="button" @click="otvoriModalDobavljac()"
style="padding:6px 14px;background:var(--kartica);color:var(--tekst-sporedni);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;cursor:pointer;margin-bottom:6px;transition:background 0.2s;"
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
+ Novi dobavljač
</button>
</div>
<select name="dobavljac_id" x-ref="selDobavljac" style="width:100%;">
<option value="">— bez dobavljača —</option> <option value="">— bez dobavljača —</option>
{{range .Dobavljaci}} {{range .Dobavljaci}}
<option value="{{.ID}}">{{.Naziv}}</option> <option value="{{.ID}}">{{.Naziv}}</option>
@@ -78,6 +87,8 @@
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Artikal</th> <th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Artikal</th>
<th style="padding:8px 10px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:90px;">Količina</th> <th style="padding:8px 10px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:90px;">Količina</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Cena/kom (din)</th> <th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Cena/kom (din)</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:120px;">Marža %</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Prodajna/kom (din)</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:110px;">Ukupno</th> <th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:110px;">Ukupno</th>
<th style="width:40px;"></th> <th style="width:40px;"></th>
</tr> </tr>
@@ -87,7 +98,7 @@
<tr style="border-bottom:0.5px solid var(--ivica);"> <tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<select :name="'artikal_id[]'" x-model="stavka.artikal_id" <select :name="'artikal_id[]'" x-model="stavka.artikal_id"
:disabled="isMobile" style="width:100%;"> @change="izaberiArtikal(stavka)" :disabled="isMobile" style="width:100%;">
<option value="">— odaberi artikal —</option> <option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id"> <template x-for="a in artikliOpcije" :key="a.id">
<option :value="a.id" x-text="a.naziv"></option> <option :value="a.id" x-text="a.naziv"></option>
@@ -96,10 +107,22 @@
</td> </td>
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" <input type="number" :name="'kolicina[]'" x-model="stavka.kolicina"
@input="preracunajSve()"
min="1" :disabled="isMobile" style="width:100%;text-align:center;"> min="1" :disabled="isMobile" style="width:100%;text-align:center;">
</td> </td>
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" <input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena"
@input="preracunajSve()"
min="0" step="0.01" :disabled="isMobile" style="width:100%;text-align:right;">
</td>
<td style="padding:8px 10px;">
<input type="number" :name="'marza[]'" x-model="stavka.marza"
@input="izracunajProdajnu(stavka)"
min="0" step="0.01" :disabled="isMobile" style="width:100%;text-align:right;">
</td>
<td style="padding:8px 10px;">
<input type="number" :name="'prodajna[]'" x-model="stavka.prodajna"
@input="izracunajMarzu(stavka)"
min="0" step="0.01" :disabled="isMobile" style="width:100%;text-align:right;"> min="0" step="0.01" :disabled="isMobile" style="width:100%;text-align:right;">
</td> </td>
<td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);"> <td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
@@ -118,7 +141,7 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr style="border-top:0.5px solid var(--ivica);"> <tr style="border-top:0.5px solid var(--ivica);">
<td colspan="3" style="padding:10px 10px;text-align:right;font-size:13px;color:var(--tekst-sporedni);font-weight:500;">Ukupno:</td> <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);"> <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="ukupnoSvega() + ' din'"></span>
</td> </td>
@@ -146,7 +169,7 @@
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Artikal</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Artikal</label>
<select :name="'artikal_id[]'" x-model="stavka.artikal_id" <select :name="'artikal_id[]'" x-model="stavka.artikal_id"
:disabled="!isMobile" style="width:100%;"> @change="izaberiArtikal(stavka)" :disabled="!isMobile" style="width:100%;">
<option value="">— odaberi artikal —</option> <option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id"> <template x-for="a in artikliOpcije" :key="a.id">
<option :value="a.id" x-text="a.naziv"></option> <option :value="a.id" x-text="a.naziv"></option>
@@ -156,11 +179,21 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Količina</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Količina</label>
<input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" min="1" :disabled="!isMobile" style="width:100%;"> <input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" @input="preracunajSve()" min="1" :disabled="!isMobile" style="width:100%;">
</div> </div>
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Cena/kom (din)</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Cena/kom (din)</label>
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" :disabled="!isMobile" style="width:100%;"> <input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" @input="preracunajSve()" min="0" step="0.01" :disabled="!isMobile" style="width:100%;">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Marža %</label>
<input type="number" :name="'marza[]'" x-model="stavka.marza" @input="izracunajProdajnu(stavka)" min="0" step="0.01" :disabled="!isMobile" style="width:100%;">
</div>
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Prodajna/kom (din)</label>
<input type="number" :name="'prodajna[]'" x-model="stavka.prodajna" @input="izracunajMarzu(stavka)" min="0" step="0.01" :disabled="!isMobile" style="width:100%;">
</div> </div>
</div> </div>
<div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);"> <div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
@@ -175,6 +208,50 @@
</div> </div>
</div> </div>
<!-- zavisni troškovi -->
<div class="kartica forma-kartica animiraj" style="margin-bottom:16px;">
<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Zavisni troškovi</span>
<button type="button" @click="dodajTrosak()" class="btn-primarno" style="font-size:13px;padding:6px 14px;">
+ Dodaj trošak
</button>
</div>
<template x-if="troskovi.length > 0">
<div class="kolona" style="gap:10px;">
<!-- metod raspodele -->
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<label class="polje-labela" style="margin:0;">Raspodela na stavke:</label>
<select name="metod_raspodele" x-model="metodRaspodele" @change="preracunajSve()" style="max-width:240px;">
<option value="vrednost">po vrednosti stavke</option>
<option value="kolicina">po količini</option>
</select>
</div>
<!-- redovi troškova -->
<template x-for="(t, i) in troskovi" :key="i">
<div style="display:flex;gap:8px;align-items:center;">
<input type="text" :name="'trosak_naziv[]'" x-model="t.naziv"
placeholder="npr. Prevoz, Carina, Špedicija" style="flex:1;">
<input type="number" :name="'trosak_iznos[]'" x-model="t.iznos"
@input="preracunajSve()" min="0" step="0.01"
placeholder="iznos (din)" style="width:140px;text-align:right;">
<button type="button" @click="ukloniTrosak(i)"
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:18px;line-height:1;padding:2px 6px;border-radius:4px;"
title="Ukloni trošak">×</button>
</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>
</div>
</div>
</template>
<template x-if="troskovi.length === 0">
<div style="font-size:13px;color:var(--tekst-sporedni);">
Nema zavisnih troškova. Dodaj trošak (prevoz, carina…) — raspodeliće se na stavke i ući u kalkulativnu nabavnu cenu.
</div>
</template>
</div>
<!-- dugmad forme --> <!-- dugmad forme -->
<div style="display:flex;justify-content:flex-end;gap:10px;"> <div style="display:flex;justify-content:flex-end;gap:10px;">
<a href="/nabavke" class="btn-sekundarno">Odustani</a> <a href="/nabavke" class="btn-sekundarno">Odustani</a>
@@ -188,10 +265,10 @@
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;" class="modal-overlay"
@click.self="zatvoriModal()" @keydown.escape.window="zatvoriModal()"> @click.self="zatvoriModal()" @keydown.escape.window="zatvoriModal()">
<div class="kartica modal-sadrzaj" style="width:100%;max-width:440px;padding:24px;"> <div class="kartica modal-sadrzaj" style="width:100%;max-width:480px;padding:24px;max-height:90vh;overflow-y:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Novi artikal</span> <span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Novi artikal</span>
@@ -222,12 +299,44 @@
</select> </select>
</div> </div>
<div>
<label class="polje-labela">Opis</label>
<textarea x-model="modalOpis" rows="2"
placeholder="Kratak opis artikla..."
style="width:100%;resize:vertical;"></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">Količina na stanju</label>
<input type="number" x-model="modalKolicina" min="0" placeholder="0" style="width:100%;">
</div>
<div>
<label class="polje-labela">Minimalna količina</label>
<input type="number" x-model="modalKolicinaMin" min="0" placeholder="0" style="width:100%;">
</div>
</div>
<div> <div>
<label class="polje-labela">Prodajna cena (din)</label> <label class="polje-labela">Prodajna cena (din)</label>
<input type="number" x-model="modalCena" min="0" step="0.01" <input type="number" x-model="modalCena" min="0" step="0.01"
placeholder="0" placeholder="0"
style="width:100%;"> style="width:100%;">
</div> </div>
<div>
<label class="polje-labela">Lokacija u magacinu</label>
<input type="text" x-model="modalLokacija"
placeholder="npr. Regal A3, polica 2"
style="width:100%;">
</div>
<div>
<label class="polje-labela">Napomena</label>
<textarea x-model="modalNapomena" rows="2"
placeholder="Interna napomena o artiklu..."
style="width:100%;resize:vertical;"></textarea>
</div>
</div> </div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;padding-top:14px;border-top:0.5px solid var(--ivica);"> <div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;padding-top:14px;border-top:0.5px solid var(--ivica);">
@@ -241,5 +350,76 @@
</div> </div>
</div> </div>
<!-- modal: novi dobavljač -->
<div x-show="modalDob" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="modal-overlay"
@click.self="zatvoriModalDobavljac()" @keydown.escape.window="zatvoriModalDobavljac()">
<div class="kartica modal-sadrzaj" style="width:100%;max-width:480px;padding:24px;max-height:90vh;overflow-y:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Novi dobavljač</span>
<button type="button" @click="zatvoriModalDobavljac()" aria-label="Zatvori"
style="background:none;border:none;cursor:pointer;color:var(--tekst-sporedni);font-size:20px;line-height:1;padding:2px 6px;border-radius:4px;transition:background 0.15s;">×</button>
</div>
<div x-show="modalDobGreska" class="poruka-greska greska-animacija" x-text="modalDobGreska"></div>
<div class="kolona" style="gap:14px;">
<div>
<label class="polje-labela">Naziv <span style="color:#dc2626;">*</span></label>
<input type="text" x-model="modalDobNaziv" x-ref="modalDobNazivInput"
placeholder="npr. TechDistrib d.o.o." style="width:100%;"
@keydown.enter.prevent="sacuvajDobavljaca()">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">Kontakt osoba</label>
<input type="text" x-model="modalDobKontakt" placeholder="npr. Marko Petrović" style="width:100%;">
</div>
<div>
<label class="polje-labela">Telefon</label>
<input type="text" x-model="modalDobTelefon" placeholder="npr. 011 123 4567" style="width:100%;">
</div>
</div>
<div>
<label class="polje-labela">E-pošta</label>
<input type="text" x-model="modalDobEmail" placeholder="npr. nabavka@techdistrib.rs" style="width:100%;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label class="polje-labela">PIB</label>
<input type="text" x-model="modalDobPib" placeholder="npr. 123456789" style="width:100%;">
</div>
<div>
<label class="polje-labela">Mesto / grad</label>
<input type="text" x-model="modalDobMesto" placeholder="npr. Beograd" style="width:100%;">
</div>
</div>
<div>
<label class="polje-labela">Napomena</label>
<textarea x-model="modalDobNapomena" rows="2"
placeholder="Interna napomena o dobavljaču..."
style="width:100%;resize:vertical;"></textarea>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;padding-top:14px;border-top:0.5px solid var(--ivica);">
<button type="button" @click="zatvoriModalDobavljac()" class="btn-sekundarno">Odustani</button>
<button type="button" @click="sacuvajDobavljaca()" :disabled="modalDobUcitavanje" class="btn-primarno"
:style="modalDobUcitavanje ? 'opacity:0.6;cursor:not-allowed' : ''">
<span x-text="modalDobUcitavanje ? 'Čuvanje...' : 'Dodaj dobavljača'"></span>
</button>
</div>
</div>
</div>
</div> </div>
{{end}} {{end}}
+69
View File
@@ -0,0 +1,69 @@
{{template "base" .}}
{{define "naslov"}}Nivelacije — promene cena — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:100%;">
<!-- filter perioda -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<form method="GET" action="/nivelacije" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div>
<label class="polje-labela">Od datuma</label>
<input type="date" name="od" value="{{.Od}}" style="padding:8px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div>
<label class="polje-labela">Do datuma</label>
<input type="date" name="do" value="{{.Do}}" style="padding:8px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<button type="submit" style="padding:8px 16px;background:var(--sb-aktivan);color:var(--tekst-jak);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;cursor:pointer;">Prikaži</button>
{{if or .Od .Do}}<a href="/nivelacije" class="nazad-link" style="margin-bottom:0;">Poništi filter</a>{{end}}
</form>
</div>
<!-- istorija nivelacija -->
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:760px;">
<thead>
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
<th style="padding:10px 12px;">Datum</th>
<th style="padding:10px 12px;">Artikal</th>
<th style="padding:10px 12px;text-align:right;">Stara cena</th>
<th style="padding:10px 12px;text-align:right;">Nova cena</th>
<th style="padding:10px 12px;text-align:right;">Razlika</th>
<th style="padding:10px 12px;">Izvor</th>
<th style="padding:10px 12px;">Razlog</th>
<th style="padding:10px 12px;">Korisnik</th>
</tr>
</thead>
<tbody>
{{range .Zapisi}}
<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;white-space:nowrap;color:{{if .Poskupljenje}}var(--greska){{else}}var(--uspeh){{end}};">
{{if .Poskupljenje}}+{{end}}{{printf "%.2f" .Razlika}}
<div style="font-size:11px;">{{if .Poskupljenje}}+{{end}}{{printf "%.1f" .Procenat}}%</div>
</td>
<td style="padding:10px 12px;">
{{if eq .Izvor "rucno"}}<span title="Posebna akcija „Promeni cenu“">ručno</span>
{{else if eq .Izvor "izmena"}}<span title="Promenjeno kroz izmenu artikla">izmena</span>
{{else if eq .Izvor "kalkulacija"}}<span title="Iz kalkulacije pri prijemu robe">kalkulacija</span>
{{else}}{{.Izvor}}{{end}}
</td>
<td style="padding:10px 12px;color:var(--tekst-sporedni);">{{if .Razlog}}{{.Razlog}}{{else}}—{{end}}</td>
<td style="padding:10px 12px;color:var(--tekst-sporedni);">{{if .KorisnikIme}}{{.KorisnikIme}}{{else}}—{{end}}</td>
</tr>
{{else}}
<tr><td colspan="8" style="padding:28px;text-align:center;color:var(--tekst-sporedni);">Nema zabeleženih promena cena u izabranom periodu.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
{{end}}
+90
View File
@@ -0,0 +1,90 @@
{{template "base" .}}
{{define "naslov"}}KIR — knjiga izdatih računa — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:100%;">
<!-- zaglavlje: filter perioda + dugme za novi zapis -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;">
<form method="GET" action="/pdv/kir" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div>
<label class="polje-labela">Od datuma</label>
<input type="date" name="od" value="{{.Od}}" style="padding:8px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div>
<label class="polje-labela">Do datuma</label>
<input type="date" name="do" value="{{.Do}}" style="padding:8px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<button type="submit" style="padding:8px 16px;background:var(--sb-aktivan);color:var(--tekst-jak);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;cursor:pointer;">Prikaži</button>
{{if or .Od .Do}}<a href="/pdv/kir" class="nazad-link" style="margin-bottom:0;">Poništi filter</a>{{end}}
</form>
<a href="/pdv/kir/nova" class="btn-primarno" style="font-size:13px;padding:8px 16px;white-space:nowrap;">+ Nov izlazni račun</a>
</div>
</div>
<!-- knjiga -->
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:900px;">
<thead>
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
<th style="padding:10px 12px;">Datum prometa</th>
<th style="padding:10px 12px;">Broj dok.</th>
<th style="padding:10px 12px;">Kupac</th>
<th style="padding:10px 12px;text-align:right;">Osn. 20%</th>
<th style="padding:10px 12px;text-align:right;">PDV 20%</th>
<th style="padding:10px 12px;text-align:right;">Osn. 10%</th>
<th style="padding:10px 12px;text-align:right;">PDV 10%</th>
<th style="padding:10px 12px;text-align:right;">Oslobođeno</th>
<th style="padding:10px 12px;text-align:right;">Ukupno</th>
<th style="padding:10px 12px;"></th>
</tr>
</thead>
<tbody>
{{range .Zapisi}}
<tr style="border-bottom:0.5px solid var(--ivica);">
<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;white-space:nowrap;">
{{if eq .Izvor "rucno"}}
<form method="POST" action="/pdv/kir/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo" data-potvrda="Obrisati ovaj zapis iz KIR?">Obriši</button>
</form>
{{else}}
<span style="font-size:11px;color:var(--tekst-sporedni);" title="Zavedeno automatski — upravlja se preko prodaje (storniraj/obriši prodaju)">iz prodaje</span>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="10" style="padding:28px;text-align:center;color:var(--tekst-sporedni);">Nema zapisa u izabranom periodu. <a href="/pdv/kir/nova" style="color:var(--sb-akcent);">Dodaj prvi.</a></td></tr>
{{end}}
</tbody>
{{if .Zapisi}}
<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></td>
</tr>
</tfoot>
{{end}}
</table>
</div>
</div>
</div>
{{end}}
+123
View File
@@ -0,0 +1,123 @@
{{template "base" .}}
{{define "naslov"}}Novi izlazni račun (KIR) — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:760px;">
<a href="/pdv/kir" class="nazad-link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
Nazad na KIR
</a>
<form method="POST" action="/pdv/kir/nova">
<!-- dokument -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">Dokument</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="polje-labela">Datum prometa</label>
<input type="date" name="datum_prometa" value="{{.Danas}}" 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">Datum knjiženja</label>
<input type="date" name="datum_knjizenja" value="{{.Danas}}" 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 style="grid-column:1 / -1;">
<label class="polje-labela">Broj dokumenta</label>
<input type="text" name="broj_dokumenta" 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;" placeholder="npr. R-2026/001">
</div>
</div>
</div>
<!-- kupac -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">Kupac</div>
<div class="kolona" style="gap:14px;">
{{if .Klijenti}}
<div>
<label class="polje-labela">Izaberi iz klijenata (opciono)</label>
<select onchange="popuniKupca(this)" 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;">
<option value="">— ručni unos —</option>
{{range .Klijenti}}
<option data-naziv="{{.PunoIme}}" data-pib="{{if eq .Tip "pravno"}}{{.PIB}}{{else}}{{.JMBG}}{{end}}" data-mesto="{{.Mesto}}">{{.PunoIme}}{{if .Mesto}} — {{.Mesto}}{{end}}</option>
{{end}}
</select>
<div class="pomocni-tekst" style="font-size:12px;margin-top:4px;">Izbor popunjava naziv i PIB/JMBG; možeš ih i ručno izmeniti.</div>
</div>
{{end}}
<div>
<label class="polje-labela">Naziv kupca</label>
<input type="text" name="kupac_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 style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="polje-labela">PIB / JMBG</label>
<input type="text" name="kupac_pib" 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">Mesto</label>
<input type="text" name="kupac_mesto" 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>
</div>
<!-- iznosi po stopama -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:6px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">Iznosi po stopama</div>
<div class="pomocni-tekst" style="font-size:12px;margin-bottom:14px;">Ostavite 0 gde nema prometa. „Ukupno" se računa automatski kao zbir osnovica, PDV-a i oslobođenog prometa.</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="polje-labela">Osnovica opšta (20%)</label>
<input type="number" step="0.01" min="0" name="osnovica_opsta" value="0" 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">PDV opšta (20%)</label>
<input type="number" step="0.01" min="0" name="pdv_opsta" value="0" 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">Osnovica posebna (10%)</label>
<input type="number" step="0.01" min="0" name="osnovica_posebna" value="0" 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">PDV posebna (10%)</label>
<input type="number" step="0.01" min="0" name="pdv_posebna" value="0" 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">Oslobođeno sa pravom na odbitak</label>
<input type="number" step="0.01" min="0" name="osloboden_sa_pravom" value="0" 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">Oslobođeno bez prava na odbitak</label>
<input type="number" step="0.01" min="0" name="osloboden_bez_prava" value="0" 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 style="margin-top:14px;">
<label class="polje-labela">Napomena</label>
<input type="text" name="napomena" 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 style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
<a href="/pdv/kir" class="btn-sekundarno" style="font-size:14px;padding:10px 20px;">Odustani</a>
<button type="submit" style="background:var(--sb-akcent);color:#fff;border:none;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">Sačuvaj</button>
</div>
</div>
</form>
</div>
<script>
// Izbor klijenta iz padajuće liste popunjava polja kupca (naziv, PIB/JMBG, mesto).
// KIR i dalje čuva kupca kao tekst — klijent samo popuni polja, koja se mogu i ručno menjati.
function popuniKupca(sel) {
var o = sel.options[sel.selectedIndex];
var naziv = o.getAttribute('data-naziv');
if (!naziv) return; // „— ručni unos —"
var f = sel.closest('form');
f.querySelector('input[name="kupac_naziv"]').value = naziv;
f.querySelector('input[name="kupac_pib"]').value = o.getAttribute('data-pib') || '';
f.querySelector('input[name="kupac_mesto"]').value = o.getAttribute('data-mesto') || '';
}
</script>
{{end}}
+93
View File
@@ -0,0 +1,93 @@
{{template "base" .}}
{{define "naslov"}}KPR — knjiga primljenih računa — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:100%;">
<!-- zaglavlje: filter perioda + dugme za novi zapis -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;">
<form method="GET" action="/pdv/kpr" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div>
<label class="polje-labela">Od datuma</label>
<input type="date" name="od" value="{{.Od}}" style="padding:8px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div>
<label class="polje-labela">Do datuma</label>
<input type="date" name="do" value="{{.Do}}" style="padding:8px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<button type="submit" style="padding:8px 16px;background:var(--sb-aktivan);color:var(--tekst-jak);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;cursor:pointer;">Prikaži</button>
{{if or .Od .Do}}<a href="/pdv/kpr" class="nazad-link" style="margin-bottom:0;">Poništi filter</a>{{end}}
</form>
<a href="/pdv/kpr/nova" class="btn-primarno" style="font-size:13px;padding:8px 16px;white-space:nowrap;">+ Nov ulazni račun</a>
</div>
</div>
<!-- knjiga -->
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:980px;">
<thead>
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
<th style="padding:10px 12px;">Datum prometa</th>
<th style="padding:10px 12px;">Broj dok.</th>
<th style="padding:10px 12px;">Dobavljač</th>
<th style="padding:10px 12px;text-align:right;">Osn. 20%</th>
<th style="padding:10px 12px;text-align:right;">PDV 20%</th>
<th style="padding:10px 12px;text-align:right;">Osn. 10%</th>
<th style="padding:10px 12px;text-align:right;">PDV 10%</th>
<th style="padding:10px 12px;text-align:right;">PDV bez odb.</th>
<th style="padding:10px 12px;text-align:right;">Oslob. nab.</th>
<th style="padding:10px 12px;text-align:right;">Ukupno</th>
<th style="padding:10px 12px;"></th>
</tr>
</thead>
<tbody>
{{range .Zapisi}}
<tr style="border-bottom:0.5px solid var(--ivica);">
<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;white-space:nowrap;">
{{if eq .Izvor "rucno"}}
<form method="POST" action="/pdv/kpr/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo" data-potvrda="Obrisati ovaj zapis iz KPR?">Obriši</button>
</form>
{{else}}
<span style="font-size:11px;color:var(--tekst-sporedni);" title="Zavedeno automatski — upravlja se preko nabavke (obriši nabavku)">iz nabavke</span>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="11" style="padding:28px;text-align:center;color:var(--tekst-sporedni);">Nema zapisa u izabranom periodu. <a href="/pdv/kpr/nova" style="color:var(--sb-akcent);">Dodaj prvi.</a></td></tr>
{{end}}
</tbody>
{{if .Zapisi}}
<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></td>
</tr>
</tfoot>
{{end}}
</table>
</div>
</div>
</div>
{{end}}
+136
View File
@@ -0,0 +1,136 @@
{{template "base" .}}
{{define "naslov"}}Novi ulazni račun (KPR) — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:760px;">
<a href="/pdv/kpr" class="nazad-link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
Nazad na KPR
</a>
<form method="POST" action="/pdv/kpr/nova">
<!-- dokument -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">Dokument</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="polje-labela">Datum prometa</label>
<input type="date" name="datum_prometa" value="{{.Danas}}" 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">Datum knjiženja</label>
<input type="date" name="datum_knjizenja" value="{{.Danas}}" 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">Datum plaćanja (opciono)</label>
<input type="date" name="datum_placanja" 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">Broj dokumenta</label>
<input type="text" name="broj_dokumenta" 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;" placeholder="npr. 2026-1234">
</div>
</div>
</div>
<!-- dobavljač -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">Dobavljač</div>
<div class="kolona" style="gap:14px;">
{{if .Dobavljaci}}
<div>
<label class="polje-labela">Izaberi iz dobavljača (opciono)</label>
<select onchange="popuniDobavljaca(this)" 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;">
<option value="">— ručni unos —</option>
{{range .Dobavljaci}}
<option data-naziv="{{.Naziv}}" data-pib="{{.PIB}}" data-mesto="{{.Mesto}}">{{.Naziv}}{{if .Mesto}} — {{.Mesto}}{{end}}</option>
{{end}}
</select>
<div class="pomocni-tekst" style="font-size:12px;margin-top:4px;">Izbor popunjava naziv, PIB i mesto; možeš ih i ručno izmeniti.</div>
</div>
{{end}}
<div>
<label class="polje-labela">Naziv dobavljača</label>
<input type="text" name="dobavljac_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 style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="polje-labela">PIB</label>
<input type="text" name="dobavljac_pib" 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">Mesto</label>
<input type="text" name="dobavljac_mesto" 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>
</div>
<!-- iznosi po stopama -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:6px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">Iznosi po stopama</div>
<div class="pomocni-tekst" style="font-size:12px;margin-bottom:14px;">PDV (opšta/posebna) je iznos koji se može odbiti. „Ukupno" se računa automatski.</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="polje-labela">Osnovica opšta (20%)</label>
<input type="number" step="0.01" min="0" name="osnovica_opsta" value="0" 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">PDV opšta (20%, odbitni)</label>
<input type="number" step="0.01" min="0" name="pdv_opsta" value="0" 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">Osnovica posebna (10%)</label>
<input type="number" step="0.01" min="0" name="osnovica_posebna" value="0" 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">PDV posebna (10%, odbitni)</label>
<input type="number" step="0.01" min="0" name="pdv_posebna" value="0" 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">PDV bez prava na odbitak</label>
<input type="number" step="0.01" min="0" name="pdv_bez_odbitka" value="0" 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">Oslobođena nabavka / bez PDV</label>
<input type="number" step="0.01" min="0" name="osloboden_nabavka" value="0" 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 style="margin-top:14px;">
<label class="polje-labela">Napomena</label>
<input type="text" name="napomena" 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 style="margin-top:14px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;color:var(--tekst-glavni);">
<input type="checkbox" name="uvoz" value="1" style="width:16px;height:16px;cursor:pointer;">
Uvoz (JCI)
</label>
<div class="pomocni-tekst" style="margin-top:4px;">
Označi ako je račun uvoz po carinskom dokumentu (JCI) — PDV ide u polja 006/106 obrasca PPPDV.
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
<a href="/pdv/kpr" class="btn-sekundarno" style="font-size:14px;padding:10px 20px;">Odustani</a>
<button type="submit" style="background:var(--sb-akcent);color:#fff;border:none;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">Sačuvaj</button>
</div>
</div>
</form>
</div>
<script>
// Izbor dobavljača iz padajuće liste popunjava polja (naziv, PIB, mesto).
function popuniDobavljaca(sel) {
var o = sel.options[sel.selectedIndex];
var naziv = o.getAttribute('data-naziv');
if (!naziv) return; // „— ručni unos —"
var f = sel.closest('form');
f.querySelector('input[name="dobavljac_naziv"]').value = naziv;
f.querySelector('input[name="dobavljac_pib"]').value = o.getAttribute('data-pib') || '';
f.querySelector('input[name="dobavljac_mesto"]').value = o.getAttribute('data-mesto') || '';
}
</script>
{{end}}
+200
View File
@@ -0,0 +1,200 @@
{{template "base" .}}
{{define "naslov"}}PDV obračun — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:100%;">
<!-- izbor perioda -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<form method="GET" action="/pdv/obracun" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div>
<label class="polje-labela">Od datuma</label>
<input type="date" name="od" value="{{.Od}}" style="padding:8px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div>
<label class="polje-labela">Do datuma</label>
<input type="date" name="do" value="{{.Do}}" style="padding:8px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<button type="submit" style="padding:8px 16px;background:var(--sb-aktivan);color:var(--tekst-jak);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;cursor:pointer;">Prikaži</button>
</form>
</div>
<!-- obračun po stopama -->
<div class="kartica animiraj" style="padding:0;overflow:hidden;margin-bottom:16px;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:560px;">
<thead>
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
<th style="padding:10px 12px;"></th>
<th style="padding:10px 12px;text-align:right;">Osnovica</th>
<th style="padding:10px 12px;text-align:right;">PDV</th>
</tr>
</thead>
<tbody>
<!-- izlazni (dugovani) PDV — iz KIR -->
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;font-weight:500;" colspan="3">Izlazni (dugovani) PDV — KIR</td>
</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>
</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>
</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);"></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>
</tr>
<!-- odbitni (prethodni) PDV — iz KPR -->
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;font-weight:500;padding-top:18px;" colspan="3">Odbitni (prethodni) PDV — KPR</td>
</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>
</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>
</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>
</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>
</tr>
</tbody>
</table>
</div>
</div>
<!-- rezultat: obaveza za uplatu / povraćaj -->
<div class="kartica animiraj" style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;
border-left:4px solid {{if .Obracun.ZaUplatu}}var(--greska){{else}}var(--uspeh){{end}};">
<div>
<div style="font-size:13px;color:var(--tekst-sporedni);">
{{if .Obracun.ZaUplatu}}PDV obaveza za uplatu{{else}}PDV za povraćaj / prenos u naredni period{{end}}
</div>
<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
</div>
</div>
<!-- PPPDV — prikaz polja zvanične poreske prijave PDV -->
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
<div style="padding:16px 16px 0;">
<div style="font-size:15px;font-weight:600;color:var(--tekst-glavni);">PPPDV — poreska prijava PDV</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:4px;">
Prikaz polja obrasca za izabrani period, u celim dinarima.
⚠ Uvoz (006/106) i PDV nadoknada poljoprivredniku (007/107) se ne vode zasebno u programu.
</div>
</div>
<div style="overflow-x:auto;margin-top:12px;">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:560px;">
<thead>
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
<th style="padding:10px 12px;width:48px;">Polje</th>
<th style="padding:10px 12px;">Opis</th>
<th style="padding:10px 12px;text-align:right;">Naknada bez PDV</th>
<th style="padding:10px 12px;text-align:right;">PDV</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:0.5px solid var(--ivica);"><td style="padding:8px 12px;font-weight:500;" colspan="4">I. Promet dobara i usluga</td></tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 12px;color:var(--tekst-sporedni);">001</td>
<td style="padding:8px 12px;">Oslobođen sa pravom na odbitak</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje001}}</td>
<td style="padding:8px 12px;text-align:right;color:var(--tekst-sporedni);"></td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 12px;color:var(--tekst-sporedni);">002</td>
<td style="padding:8px 12px;">Oslobođen bez prava na odbitak</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje002}}</td>
<td style="padding:8px 12px;text-align:right;color:var(--tekst-sporedni);"></td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 12px;color:var(--tekst-sporedni);">003<div style="font-size:11px;">103</div></td>
<td style="padding:8px 12px;">Promet po opštoj stopi (20%)</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje003}}</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje103}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 12px;color:var(--tekst-sporedni);">004<div style="font-size:11px;">104</div></td>
<td style="padding:8px 12px;">Promet po posebnoj stopi (10%)</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje004}}</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje104}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);font-weight:500;background:var(--pozadina);">
<td style="padding:8px 12px;">005<div style="font-size:11px;">105</div></td>
<td style="padding:8px 12px;">ZBIR (1+2+3+4)</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje005}}</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje105}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);"><td style="padding:8px 12px;font-weight:500;padding-top:14px;" colspan="4">II. Prethodni porez</td></tr>
<tr style="border-bottom:0.5px solid var(--ivica);color:var(--tekst-sporedni);">
<td style="padding:8px 12px;">006<div style="font-size:11px;">106</div></td>
<td style="padding:8px 12px;">Prethodni porez pri uvozu</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje006}}</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje106}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);color:var(--tekst-sporedni);">
<td style="padding:8px 12px;">007<div style="font-size:11px;">107</div></td>
<td style="padding:8px 12px;">PDV nadoknada poljoprivredniku</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje007}}</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje107}}</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 12px;color:var(--tekst-sporedni);">008<div style="font-size:11px;">108</div></td>
<td style="padding:8px 12px;">Ostali prethodni porez (domaće nabavke)</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje008}}</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje108}}</td>
</tr>
<tr style="font-weight:500;background:var(--pozadina);">
<td style="padding:8px 12px;">009<div style="font-size:11px;">109</div></td>
<td style="padding:8px 12px;">ZBIR (6+7+8)</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje009}}</td>
<td style="padding:8px 12px;text-align:right;">{{.PPPDV.Polje109}}</td>
</tr>
</tbody>
<tfoot>
<tr style="border-top:0.5px solid var(--ivica);font-weight:600;">
<td style="padding:10px 12px;">110</td>
<td style="padding:10px 12px;" colspan="2">
Iznos PDV u periodu (105 109)
{{if .PPPDV.Povracaj}}<span style="color:var(--uspeh);"> — za povraćaj</span>{{else}}<span style="color:var(--greska);"> — za uplatu</span>{{end}}
</td>
<td style="padding:10px 12px;text-align:right;color:{{if .PPPDV.Povracaj}}var(--uspeh){{else}}var(--greska){{end}};">{{.PPPDV.Polje110Apsolutno}}</td>
</tr>
<tr>
<td style="padding:8px 12px;color:var(--tekst-sporedni);">11</td>
<td style="padding:8px 12px;" colspan="3">Povraćaj: <strong>{{if .PPPDV.Povracaj}}DA{{else}}NE{{end}}</strong></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
{{end}}
+115
View File
@@ -0,0 +1,115 @@
{{template "base" .}}
{{define "naslov"}}Podešavanja — Kalkulacija i PDV — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:100%;">
<!-- kalkulacija: podrazumevana marža za formiranje prodajne cene pri nabavci -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Kalkulacija</span>
</div>
<form method="POST" action="/podesavanja/sacuvaj">
<input type="hidden" name="_next" value="/admin/podesavanja/kalkulacija-pdv">
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-end;">
<div>
<label for="kalkulacija_marza" class="polje-labela">Podrazumevana marža (%)</label>
<input type="number" id="kalkulacija_marza" name="kalkulacija_marza" min="0" max="1000" step="0.01" value="{{.KalkulacijaMarza}}"
style="width:140px;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;">
</div>
<button type="submit"
style="padding:9px 18px;background:var(--sb-akcent);border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;">
Sačuvaj
</button>
</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:8px;">
Početna marža za formiranje prodajne cene pri nabavci. Po stavci je možeš promeniti.
</div>
</form>
</div>
<!-- postojeće stope: svaki red je forma za izmenu + zasebna forma za arhiviranje -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Šifarnik PDV stopa</span>
</div>
<div class="pomocni-tekst" style="font-size:12px;margin-bottom:14px;">
Stope se koriste u kalkulaciji cena i PDV evidenciji. Stopa koja je negde upotrebljena se ne briše — arhivira se (nestaje iz padajućih lista, a postojeći zapisi ostaju ispravni).
</div>
{{if not .Stope}}
<div class="pomocni-tekst">Nema unetih PDV stopa.</div>
{{end}}
{{range .Stope}}
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;padding:10px 0;border-bottom:0.5px solid var(--ivica);{{if not .Aktivna}}opacity:0.5;{{end}}">
<form method="POST" action="/podesavanja/pdv-stope/{{.ID}}/izmeni" style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;flex:1;">
<div style="flex:2;min-width:140px;">
<label class="polje-labela">Naziv</label>
<input type="text" name="naziv" value="{{.Naziv}}" style="width:100%;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div style="width:80px;">
<label class="polje-labela">Stopa %</label>
<input type="text" name="stopa" value="{{printf "%g" .Stopa}}" style="width:100%;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div style="width:150px;">
<label class="polje-labela">Vrsta</label>
<select name="oznaka" style="width:100%;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
<option value="opsta" {{if eq .Oznaka "opsta"}}selected{{end}}>Opšta</option>
<option value="posebna" {{if eq .Oznaka "posebna"}}selected{{end}}>Posebna</option>
<option value="oslobodjeno" {{if eq .Oznaka "oslobodjeno"}}selected{{end}}>Oslobođeno</option>
</select>
</div>
<div style="width:80px;">
<label class="polje-labela">Redosled</label>
<input type="text" name="redosled" value="{{.Redosled}}" style="width:100%;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<button type="submit" style="padding:7px 14px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;white-space:nowrap;">Sačuvaj</button>
</form>
<form method="POST" action="/podesavanja/pdv-stope/{{.ID}}/aktivnost">
<button type="submit" style="padding:7px 14px;background:var(--sb-aktivan);color:var(--tekst-jak);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;cursor:pointer;white-space:nowrap;">{{if .Aktivna}}Arhiviraj{{else}}Vrati{{end}}</button>
</form>
</div>
{{end}}
</div>
<!-- dodavanje nove stope -->
<form method="POST" action="/podesavanja/pdv-stope/dodaj">
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Nova stopa</span>
</div>
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;">
<div style="flex:2;min-width:140px;">
<label class="polje-labela">Naziv</label>
<input type="text" name="naziv" placeholder="npr. Opšta stopa" style="width:100%;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div style="width:80px;">
<label class="polje-labela">Stopa %</label>
<input type="text" name="stopa" placeholder="20" style="width:100%;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div style="width:150px;">
<label class="polje-labela">Vrsta</label>
<select name="oznaka" style="width:100%;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
<option value="opsta">Opšta</option>
<option value="posebna">Posebna</option>
<option value="oslobodjeno">Oslobođeno</option>
</select>
</div>
<div style="width:80px;">
<label class="polje-labela">Redosled</label>
<input type="text" name="redosled" placeholder="0" style="width:100%;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<button type="submit" style="padding:7px 16px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;">Dodaj</button>
</div>
</div>
</form>
</div>
{{end}}
@@ -117,6 +117,94 @@
</div> </div>
</form> </form>
<!-- profil firme: pravni/poreski status (Faza 0) — određuje koji se zakonski moduli pale -->
<form method="POST" action="/podesavanja/sacuvaj">
<input type="hidden" name="_next" value="/admin/podesavanja/opste">
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Pravni i poreski status</span>
</div>
<div class="kolona" style="gap:18px;">
<!-- pravni oblik -->
<div>
<label class="polje-labela">Pravni oblik</label>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaPravniOblik "pausalac"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_pravni_oblik" value="pausalac" {{if eq .FirmaPravniOblik "pausalac"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Paušalac</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaPravniOblik "preduzetnik_knjige"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_pravni_oblik" value="preduzetnik_knjige" {{if eq .FirmaPravniOblik "preduzetnik_knjige"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Preduzetnik (knjige)</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaPravniOblik "doo"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_pravni_oblik" value="doo" {{if eq .FirmaPravniOblik "doo"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">DOO</span>
</label>
</div>
</div>
<!-- režim rada -->
<div>
<label class="polje-labela">Režim rada</label>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaRezim "pun"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_rezim" value="pun" {{if eq .FirmaRezim "pun"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Pun (vodi poslovne knjige)</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaRezim "samo_evidencija"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_rezim" value="samo_evidencija" {{if eq .FirmaRezim "samo_evidencija"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Samo evidencija</span>
</label>
</div>
<div class="pomocni-tekst" style="font-size:12px;margin-top:6px;">„Samo evidencija" gasi ceo zakonski sloj — program vodi servis i zalihe, ali ne izdaje fiskalne ni poreske dokumente.</div>
</div>
<!-- PDV obveznik -->
<div>
<label class="polje-labela">U sistemu PDV-a</label>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaPdvObveznik "da"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_pdv_obveznik" value="da" {{if eq .FirmaPdvObveznik "da"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Da</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaPdvObveznik "ne"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_pdv_obveznik" value="ne" {{if eq .FirmaPdvObveznik "ne"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Ne</span>
</label>
</div>
</div>
<!-- fiskalizacija — zasivljena i forsirana na „Ne" kad je režim „samo evidencija" (vidi skriptu na dnu) -->
<div id="fiskalizacija-grupa">
<label class="polje-labela">Izdaje fiskalne račune (promet građanima)</label>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaFiskalizacija "da"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_fiskalizacija" value="da" {{if eq .FirmaFiskalizacija "da"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Da</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaFiskalizacija "ne"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="firma_fiskalizacija" value="ne" {{if eq .FirmaFiskalizacija "ne"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Ne</span>
</label>
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-top:20px;">
<button type="submit"
style="background:var(--sb-akcent);color:#fff;border:none;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">
Sačuvaj status
</button>
</div>
</div>
</form>
</div> </div>
{{if .LogoGreska}} {{if .LogoGreska}}
@@ -135,4 +223,37 @@
})(); })();
</script> </script>
{{end}} {{end}}
<script>
// Fiskalizacija ima smisla samo u punom režimu. Kad je „samo evidencija", grupa se
// zasivi i forsira na „Ne". Ne koristimo disabled (jer disabled polje se ne šalje u
// POST pa bi stara vrednost ostala u bazi) — umesto toga pointer-events:none, a radio
// „Ne" ostaje aktivan i čekiran, pa se uredno sačuva „ne".
(function() {
var rezim = document.querySelectorAll('input[name="firma_rezim"]');
var grupa = document.getElementById('fiskalizacija-grupa');
var fisk = document.querySelectorAll('input[name="firma_fiskalizacija"]');
if (!grupa || !rezim.length) return;
function azuriraj() {
var izabran = document.querySelector('input[name="firma_rezim"]:checked');
var samoEvidencija = izabran && izabran.value === 'samo_evidencija';
grupa.style.opacity = samoEvidencija ? '0.45' : '';
grupa.style.pointerEvents = samoEvidencija ? 'none' : '';
if (samoEvidencija) {
fisk.forEach(function(r) {
r.checked = (r.value === 'ne');
var lab = r.closest('label');
if (lab) {
lab.style.borderColor = r.checked ? 'var(--sb-akcent)' : 'var(--ivica)';
lab.style.background = r.checked ? 'var(--pozadina)' : 'transparent';
}
});
}
}
rezim.forEach(function(r) { r.addEventListener('change', azuriraj); });
azuriraj();
})();
</script>
{{end}} {{end}}