Merge feature/kw-pdv-evidencija: PDV evidencija (Faza 2 — KIR/KPR, auto-punjenje, obračun, PPPDV)

This commit is contained in:
2026-06-14 09:03:27 +02:00
46 changed files with 3354 additions and 92 deletions
+41 -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/pdv-stope", 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)
@@ -327,7 +361,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 +454,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)
}
}
}
+31 -2
View File
@@ -23,6 +23,35 @@ type KategorijaRepository interface {
Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, 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
}
// ArtikalFilter definiše parametre za filtriranje liste artikala // ArtikalFilter definiše parametre za filtriranje liste artikala
type ArtikalFilter struct { type ArtikalFilter struct {
Pretraga string Pretraga string
@@ -117,8 +146,8 @@ type SesijeRepository interface {
// PodsetnikFilter definiše parametre za filtriranje liste podsetnika // PodsetnikFilter definiše parametre za filtriranje liste podsetnika
type PodsetnikFilter struct { type PodsetnikFilter struct {
SamoAktivni bool // true = samo nezavršeni; false = svi SamoAktivni bool // true = samo nezavršeni; false = svi
KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika
} }
// PokusajiPrijaveRepository definiše operacije nad evidencijom pokušaja prijave // PokusajiPrijaveRepository definiše operacije nad evidencijom pokušaja prijave
+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
} }
+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)
+288
View File
@@ -0,0 +1,288 @@
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, 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, 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
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, &k.DatumUnosa,
); err != nil {
return model.PdvKpr{}, err
}
if datumPlacanja.Valid {
k.DatumPlacanja = &datumPlacanja.Time
}
if izvorID.Valid {
k.IzvorID = &izvorID.Int64
}
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
}
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
) 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))
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")),
}, "" }, ""
} }
+23
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,9 @@ 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
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 +98,9 @@ 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),
} }
} }
@@ -118,6 +126,19 @@ 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)
}
// 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 +180,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
+4
View File
@@ -25,6 +25,10 @@ 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",
"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")),
}, "" }, ""
} }
+32 -1
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"
@@ -26,7 +27,7 @@ type PodaciNabavki struct {
type PodaciFormeNabavke struct { type PodaciFormeNabavke struct {
model.PodaciStranice model.PodaciStranice
Artikli []model.ArtikalSaKategorijom Artikli []model.ArtikalSaKategorijom
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
Greska string Greska string
@@ -155,6 +156,32 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
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)
}
}
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)
} }
@@ -221,6 +248,10 @@ 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)
} }
+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)
}
+164
View File
@@ -0,0 +1,164 @@
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")),
}
// 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)
}
+74
View File
@@ -0,0 +1,74 @@
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)
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, kprSume),
})
}
+154
View File
@@ -0,0 +1,154 @@
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 šifarnika PDV stopa
type PodaciPdvStope struct {
model.PodaciStranice
Stope []model.PdvStopa
}
// PdvStope renderuje š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-pdv-stope"
ps.NaslovStranice = "PDV stope"
h.renderujTemplate(w, "pdv_stope", PodaciPdvStope{PodaciStranice: ps, Stope: stope})
}
// 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/pdv-stope", 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/pdv-stope", 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/pdv-stope", 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/pdv-stope", 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/pdv-stope", http.StatusSeeOther)
}
+63 -50
View File
@@ -23,25 +23,30 @@ import (
// PodaciPodesavanja su podaci za stranicu podešavanja // PodaciPodesavanja su podaci za stranicu podešavanja
type PodaciPodesavanja struct { type PodaciPodesavanja struct {
model.PodaciStranice model.PodaciStranice
NazivFirme string NazivFirme string
Podnazlov string Podnazlov string
Adresa string Adresa string
Telefon string Telefon string
PIB string PIB string
LogoTip string LogoTip string
LogoPutanja string LogoPutanja string
Sacuvano bool // profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale
Verzija string FirmaPravniOblik string
LogoGreska string FirmaPdvObveznik string
BackupVracen bool FirmaFiskalizacija string
Backupi []BackupInfo FirmaRezim string
BackupIntervalSati string Sacuvano bool
BackupBrojKopija string Verzija string
LoginPozadina string LogoGreska string
LoginPozadinaOpacity string BackupVracen bool
LoginPozadinaBlurPozadine string Backupi []BackupInfo
LoginPozadinaBlurKartice string BackupIntervalSati string
LoginPozadinaZatamnjenjeKartice string BackupBrojKopija string
LoginPozadina string
LoginPozadinaOpacity string
LoginPozadinaBlurPozadine string
LoginPozadinaBlurKartice string
LoginPozadinaZatamnjenjeKartice string
} }
// BackupInfo opisuje jedan backup fajl // BackupInfo opisuje jedan backup fajl
@@ -69,19 +74,19 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
ps.Stranica = "podesavanja" ps.Stranica = "podesavanja"
ps.NaslovStranice = "Podešavanja" ps.NaslovStranice = "Podešavanja"
podaci := PodaciPodesavanja{ podaci := PodaciPodesavanja{
PodaciStranice: ps, PodaciStranice: ps,
NazivFirme: podesavanja["naziv_firme"], NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"], Podnazlov: podesavanja["podnazlov"],
Adresa: podesavanja["adresa"], Adresa: podesavanja["adresa"],
Telefon: podesavanja["telefon"], Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"], PIB: podesavanja["pib"],
LogoTip: podesavanja["logo_tip"], LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"], LogoPutanja: podesavanja["logo_putanja"],
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,
LogoGreska: r.URL.Query().Get("logo_greska"), LogoGreska: r.URL.Query().Get("logo_greska"),
Backupi: ucitajListuBackupa(), Backupi: ucitajListuBackupa(),
LoginPozadina: podesavanja["login_pozadina"], LoginPozadina: podesavanja["login_pozadina"],
LoginPozadinaOpacity: vrednostIliDefault(podesavanja, "login_pozadina_opacity", "50"), LoginPozadinaOpacity: vrednostIliDefault(podesavanja, "login_pozadina_opacity", "50"),
LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"), LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"),
@@ -234,6 +239,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 {
@@ -566,10 +576,10 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req
} }
for kljuc, vrednost := range map[string]string{ for kljuc, vrednost := range map[string]string{
"login_pozadina_blur_pozadine": blurPozadineStr, "login_pozadina_blur_pozadine": blurPozadineStr,
"login_pozadina_blur_kartice": blurKarticeStr, "login_pozadina_blur_kartice": blurKarticeStr,
"login_pozadina_opacity": opacityStr, "login_pozadina_opacity": opacityStr,
"login_pozadina_zatamnjenje_kartice": zatamnjenjeKarticeStr, "login_pozadina_zatamnjenje_kartice": zatamnjenjeKarticeStr,
} { } {
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil {
slog.Error("greška pri čuvanju stila login pozadine", "kljuc", kljuc, "error", err) slog.Error("greška pri čuvanju stila login pozadine", "kljuc", kljuc, "error", err)
@@ -593,19 +603,23 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
ps.Stranica = "podesavanja" ps.Stranica = "podesavanja"
ps.NaslovStranice = naslov ps.NaslovStranice = naslov
return PodaciPodesavanja{ return PodaciPodesavanja{
PodaciStranice: ps, PodaciStranice: ps,
NazivFirme: podesavanja["naziv_firme"], NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"], Podnazlov: podesavanja["podnazlov"],
Adresa: podesavanja["adresa"], Adresa: podesavanja["adresa"],
Telefon: podesavanja["telefon"], Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"], PIB: podesavanja["pib"],
LogoTip: podesavanja["logo_tip"], LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"], LogoPutanja: podesavanja["logo_putanja"],
Sacuvano: r.URL.Query().Get("sacuvano") == "1", FirmaPravniOblik: vrednostIliDefault(podesavanja, "firma_pravni_oblik", "pausalac"),
BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", FirmaPdvObveznik: vrednostIliDefault(podesavanja, "firma_pdv_obveznik", "ne"),
Verzija: h.Verzija, FirmaFiskalizacija: vrednostIliDefault(podesavanja, "firma_fiskalizacija", "ne"),
LogoGreska: r.URL.Query().Get("logo_greska"), FirmaRezim: vrednostIliDefault(podesavanja, "firma_rezim", "samo_evidencija"),
Backupi: ucitajListuBackupa(), Sacuvano: r.URL.Query().Get("sacuvano") == "1",
BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno",
Verzija: h.Verzija,
LogoGreska: r.URL.Query().Get("logo_greska"),
Backupi: ucitajListuBackupa(),
LoginPozadina: podesavanja["login_pozadina"], LoginPozadina: podesavanja["login_pozadina"],
LoginPozadinaOpacity: vrednostIliDefault(podesavanja, "login_pozadina_opacity", "50"), LoginPozadinaOpacity: vrednostIliDefault(podesavanja, "login_pozadina_opacity", "50"),
LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"), LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"),
@@ -657,4 +671,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)
} }
+6
View File
@@ -38,6 +38,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
@@ -95,6 +98,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)
})
}
}
+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
} }
+328
View File
@@ -0,0 +1,328 @@
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)
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).
// ⚠ Uvoz (006/106) i PDV nadoknada poljoprivredniku (007/107) se ne prate zasebno
// pa ostaju 0 — sav odbitni PDV iz KPR pada u polje 008/108.
func MapirajPPPDV(kir PdvKirSume, kpr 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),
Polje008: uDinare(kpr.OsnovicaOpsta + kpr.OsnovicaPosebna),
Polje108: uDinare(kpr.PdvOpsta + kpr.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
}
+144
View File
@@ -0,0 +1,144 @@
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)
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)
}
// uvoz i poljoprivrednik se ne prate
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})
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())
}
}
+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
}
+2 -1
View File
@@ -39,11 +39,12 @@ type PodaciStranice struct {
LogoTip string // "sa_nazivom", "bez_naziva", "slika" LogoTip string // "sa_nazivom", "bez_naziva", "slika"
LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika" LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika"
Korisnik string Korisnik string
KorisnikIme string // korisničko ime prijavljenog korisnika KorisnikIme string // korisničko ime prijavljenog korisnika
KorisnikUloga string // uloga: "superadmin", "admin", "radnik" KorisnikUloga string // uloga: "superadmin", "admin", "radnik"
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;
+81
View File
@@ -194,7 +194,22 @@ document.addEventListener('alpine:init', () => {
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.isMobile = window.matchMedia('(max-width: 768px)').matches this.isMobile = window.matchMedia('(max-width: 768px)').matches
@@ -219,7 +234,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 +255,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 +280,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
}
} }
})) }))
+27 -3
View File
@@ -93,6 +93,25 @@
</a> </a>
{{end}} {{end}}
{{/* PDV evidencija — vidljivo samo kada je modul „pdv" uključen za firmu (profil firme) i korisnik ima dozvolu */}}
{{if and (index .Moduli "pdv") (index .Dozvole "pdv.pregled")}}
<a href="/pdv/kir" class="nav-stavka {{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 {{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 {{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 class="nav-separator"></div> <div class="nav-separator"></div>
<div class="nav-oznaka">Nalog</div> <div class="nav-oznaka">Nalog</div>
@@ -147,19 +166,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-pdv-stope") (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-pdv-stope") (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-pdv-stope") (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 +194,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/pdv-stope" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-pdv-stope"}}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>PDV stope</span>
<span class="nav-tooltip">PDV stope</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>
@@ -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>
+116 -4
View File
@@ -8,6 +8,8 @@
.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}}
@@ -37,8 +39,15 @@
</div> </div>
<div class="kolona" style="gap:14px;"> <div class="kolona" style="gap:14px;">
<div> <div>
<label class="polje-labela">Dobavljač</label> <div style="display:flex;justify-content:space-between;align-items:center;">
<select name="dobavljac_id" style="width:100%;"> <label class="polje-labela">Dobavljač</label>
<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>
@@ -187,10 +196,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>
@@ -221,12 +230,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);">
@@ -240,5 +281,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}}
+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}}</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}}
+126
View File
@@ -0,0 +1,126 @@
{{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="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}}
+90
View File
@@ -0,0 +1,90 @@
{{template "base" .}}
{{define "naslov"}}Podešavanja — PDV stope — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:100%;">
<!-- 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}}