Merge branch 'develop'

This commit is contained in:
2026-07-05 11:16:26 +02:00
15 changed files with 460 additions and 74 deletions
+3 -35
View File
@@ -20,7 +20,6 @@ import (
"ntech/internal/auth" "ntech/internal/auth"
"ntech/internal/be" "ntech/internal/be"
"ntech/internal/config" "ntech/internal/config"
"ntech/internal/db"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/handler" "ntech/internal/handler"
ntechmw "ntech/internal/middleware" ntechmw "ntech/internal/middleware"
@@ -152,7 +151,7 @@ func main() {
h.JelDemo = os.Getenv("NTECH_ENV") == "demo" h.JelDemo = os.Getenv("NTECH_ENV") == "demo"
if h.JelDemo { if h.JelDemo {
h.Verzija = "DEMO - " + h.Verzija h.Verzija = "DEMO - " + h.Verzija
if err := postaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil { if err := handler.PostaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil {
slog.Warn("demo: greška pri postavljanju demo korisnika", "error", err) slog.Warn("demo: greška pri postavljanju demo korisnika", "error", err)
} }
} }
@@ -338,6 +337,7 @@ 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)
r.With(doz("backup.pokreni")).Post("/podesavanja/uvezi-bazu", h.UvezizBazu)
// PDV evidencija — KIR (knjiga izdatih računa). Dostupno samo kada je modul // PDV evidencija — KIR (knjiga izdatih računa). Dostupno samo kada je modul
// „pdv" uključen za firmu (RequireModul), uz RBAC dozvolu pdv.*. // „pdv" uključen za firmu (RequireModul), uz RBAC dozvolu pdv.*.
@@ -391,6 +391,7 @@ func main() {
r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju)
r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju) r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju)
r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju)
r.With(doz("artikal.izmeni")).Post("/magacin/kategorije/dodeli-sifre", h.DodeliSifreArtiklima)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka)
r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku) r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku)
@@ -556,39 +557,6 @@ func ucitajTotpKljuc(envFajl string) ([]byte, error) {
return kljuc, nil return kljuc, nil
} }
// postaviDemoKorisnika osigurava da pri pokretanju demo instance postoji korisnik "Demo"
// sa ulogom "admin" i resetovanom lozinkom. Poziva se samo kada je NTECH_ENV=demo.
func postaviDemoKorisnika(ctx context.Context, repo db.KorisniciRepository) error {
const (
demoIme = "Demo"
demoLozinka = "Demo1234"
)
hash, err := auth.HashujLozinku(demoLozinka)
if err != nil {
return fmt.Errorf("ntech: postaviDemoKorisnika: %w", err)
}
korisnik, err := repo.DohvatiPoImenu(ctx, demoIme)
if err != nil {
// korisnik ne postoji — kreiraj ga
if _, err := repo.Kreiraj(ctx, demoIme, hash, "admin"); err != nil {
return fmt.Errorf("ntech: postaviDemoKorisnika: kreiranje: %w", err)
}
slog.Info("demo korisnik kreiran", "korisnik", demoIme)
return nil
}
// korisnik postoji — resetuj lozinku i osiguraj da je aktivan
if err := repo.PromeniLozinku(ctx, korisnik.ID, hash); err != nil {
return fmt.Errorf("ntech: postaviDemoKorisnika: lozinka: %w", err)
}
if !korisnik.Aktivan {
if err := repo.AzurirajAktivan(ctx, korisnik.ID, true); err != nil {
return fmt.Errorf("ntech: postaviDemoKorisnika: aktivan: %w", err)
}
}
slog.Info("demo korisnik resetovan", "korisnik", demoIme)
return nil
}
// napraviBackup kreira konzistentnu kopiju baze i briše najstarije preko zadatog broja kopija. // napraviBackup kreira konzistentnu kopiju baze i briše najstarije preko zadatog broja kopija.
// Koristi već otvorenu vezu ka bazi (VACUUM INTO je bezbedan na pooled konekciji). // Koristi već otvorenu vezu ka bazi (VACUUM INTO je bezbedan na pooled konekciji).
func napraviBackup(db *sql.DB, putanjaBaze string, maxKopija int) { func napraviBackup(db *sql.DB, putanjaBaze string, maxKopija int) {
+2
View File
@@ -27,6 +27,8 @@ type ArtikalRepository interface {
Izmeni(ctx context.Context, a *model.Artikal) error Izmeni(ctx context.Context, a *model.Artikal) error
// AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci) // AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci)
AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error
// AzurirajSifru menja samo šifru artikla
AzurirajSifru(ctx context.Context, id int64, sifra string) error
PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
// Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju) // Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju)
+9
View File
@@ -304,6 +304,15 @@ func (r *ArtikalRepo) SledecaSifra(ctx context.Context, kategorijaID *int64) (st
return fmt.Sprintf("%s-%04d", prefiks, maxBroj+1), nil return fmt.Sprintf("%s-%04d", prefiks, maxBroj+1), nil
} }
// AzurirajSifru menja samo šifru artikla.
func (r *ArtikalRepo) AzurirajSifru(ctx context.Context, id int64, sifra string) error {
_, err := r.db.ExecContext(ctx, "UPDATE artikli SET sifra = ? WHERE id = ?", sifra, id)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.AzurirajSifru: %w", err)
}
return nil
}
// AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe). // AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe).
func (r *ArtikalRepo) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error { func (r *ArtikalRepo) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
+12 -3
View File
@@ -4,6 +4,7 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"ntech/internal/auth" "ntech/internal/auth"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
@@ -104,13 +105,15 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
return return
} }
ime := r.FormValue("korisnicko_ime") korisnickoIme := r.FormValue("korisnicko_ime")
lozinka := r.FormValue("lozinka") lozinka := r.FormValue("lozinka")
uloga := r.FormValue("uloga") uloga := r.FormValue("uloga")
ime := strings.TrimSpace(r.FormValue("ime"))
prezime := strings.TrimSpace(r.FormValue("prezime"))
// superadmin uloga se ne može kreirati kroz interfejs — jedini superadmin postoji od setup-a // superadmin uloga se ne može kreirati kroz interfejs — jedini superadmin postoji od setup-a
validneUloge := map[string]bool{"admin": true, "radnik": true} validneUloge := map[string]bool{"admin": true, "radnik": true}
if len(ime) < 3 || len(lozinka) < 8 || !validneUloge[uloga] { if len(korisnickoIme) < 3 || len(lozinka) < 8 || !validneUloge[uloga] || ime == "" || prezime == "" {
middleware.SetFlash(w, r, h.DB, "greska", "Proverite unete podatke.") middleware.SetFlash(w, r, h.DB, "greska", "Proverite unete podatke.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther) http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
return return
@@ -123,7 +126,13 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
return return
} }
if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, uloga); err != nil { noviKorisnik, err := h.KorisniciRepo.Kreiraj(r.Context(), korisnickoIme, hash, uloga)
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
return
}
if err := h.KorisniciRepo.AzurirajImePrezime(r.Context(), noviKorisnik.ID, ime, prezime); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther) http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
return return
+39
View File
@@ -20,6 +20,8 @@ type PodaciKategorija struct {
Kategorije []model.Kategorija Kategorije []model.Kategorija
Sacuvano bool Sacuvano bool
Obrisana bool Obrisana bool
SifreDodeljene int
PrikaziSifreDod bool
} }
// Kategorije renderuje listu kategorija // Kategorije renderuje listu kategorija
@@ -39,16 +41,53 @@ func (h *Handler) Kategorije(w http.ResponseWriter, r *http.Request) {
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "magacin" ps.Stranica = "magacin"
ps.NaslovStranice = "Kategorije" ps.NaslovStranice = "Kategorije"
sifreDodeljeneStr := r.URL.Query().Get("sifre_dodeljene")
sifreDodeljene, _ := strconv.Atoi(sifreDodeljeneStr)
podaci := PodaciKategorija{ podaci := PodaciKategorija{
PodaciStranice: ps, PodaciStranice: ps,
Kategorije: kategorije, Kategorije: kategorije,
Sacuvano: r.URL.Query().Get("sacuvano") == "1", Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisana: r.URL.Query().Get("obrisana") == "1", Obrisana: r.URL.Query().Get("obrisana") == "1",
SifreDodeljene: sifreDodeljene,
PrikaziSifreDod: sifreDodeljeneStr != "",
} }
h.renderujTemplate(w, "kategorije", podaci) h.renderujTemplate(w, "kategorije", podaci)
} }
// DodeliSifreArtiklima prolazi kroz sve artikle bez šifre i dodeljuje im
// šifru na osnovu prefiksa njihove kategorije (isti generator kao za nove
// artikle). Radi sekvencijalno — jedan artikal se upiše pre nego što se za
// sledeći izračuna predlog — da artikli iz iste kategorije ne bi dobili isti broj.
func (h *Handler) DodeliSifreArtiklima(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
return
}
artikli, err := h.Artikli.Lista(r.Context(), db.ArtikalFilter{})
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
}
broj := 0
for _, a := range artikli {
if a.Sifra != "" {
continue
}
sifra, err := h.Artikli.SledecaSifra(r.Context(), a.KategorijaID)
if err != nil {
continue
}
if err := h.Artikli.AzurirajSifru(r.Context(), a.ID, sifra); err != nil {
continue
}
broj++
}
http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther)
}
// DodajKategoriju prima POST i čuva novu kategoriju // DodajKategoriju prima POST i čuva novu kategoriju
func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok {
+5 -1
View File
@@ -23,6 +23,9 @@ type PodaciFormeArtikla struct {
KategorijaIDStr string KategorijaIDStr string
Dobavljaci []model.Dobavljac // svi dobavljači za izbor Dobavljaci []model.Dobavljac // svi dobavljači za izbor
IzabraniDobavljaci map[int64]bool // dobavljači vezani za artikal (za checked stanje) IzabraniDobavljaci map[int64]bool // dobavljači vezani za artikal (za checked stanje)
PredlogSifre string // prikazuje se kao placeholder, NIKAD kao stvarna vrednost polja —
// polje šifre mora ostati prazno da bi server na čuvanju sam izračunao
// tačan prefiks na osnovu izabrane kategorije (vidi SacuvajArtikal)
Greska string Greska string
Izmena bool Izmena bool
} }
@@ -62,7 +65,8 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Kategorije: kategorije, Kategorije: kategorije,
Dobavljaci: dobavljaci, Dobavljaci: dobavljaci,
Artikal: model.Artikal{Sifra: predlogSifre, Tip: tip, JedinicaMere: "kom", PdvStopa: h.podrazumevanaPdvStopa(r.Context())}, Artikal: model.Artikal{Tip: tip, JedinicaMere: "kom", PdvStopa: h.podrazumevanaPdvStopa(r.Context())},
PredlogSifre: predlogSifre,
Izmena: false, Izmena: false,
}) })
} }
+320
View File
@@ -1,7 +1,9 @@
package handler package handler
import ( import (
"context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"html" "html"
@@ -20,11 +22,219 @@ import (
"encoding/json" "encoding/json"
"ntech/internal/auth"
"ntech/internal/db"
ntechsqlite "ntech/internal/db/sqlite" ntechsqlite "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
) )
// PostaviDemoKorisnika osigurava da postoji korisnik "Demo" sa ulogom "admin"
// i podrazumevanom lozinkom. Poziva se pri startu demo instance, i ponovo
// posle uvoza/vraćanja baze (jer taj hot-swap donosi svoje korisnike iz
// uvezenog/vraćenog fajla, koji mogu imati drugačiju ili nepoznatu lozinku).
func PostaviDemoKorisnika(ctx context.Context, repo db.KorisniciRepository) error {
const (
demoIme = "Demo"
demoLozinka = "Demo1234"
)
hash, err := auth.HashujLozinku(demoLozinka)
if err != nil {
return fmt.Errorf("ntech: PostaviDemoKorisnika: %w", err)
}
korisnik, err := repo.DohvatiPoImenu(ctx, demoIme)
if err != nil {
// korisnik ne postoji — kreiraj ga
if _, err := repo.Kreiraj(ctx, demoIme, hash, "admin"); err != nil {
return fmt.Errorf("ntech: PostaviDemoKorisnika: kreiranje: %w", err)
}
slog.Info("demo korisnik kreiran", "korisnik", demoIme)
return nil
}
// korisnik postoji — resetuj lozinku i osiguraj da je aktivan
if err := repo.PromeniLozinku(ctx, korisnik.ID, hash); err != nil {
return fmt.Errorf("ntech: PostaviDemoKorisnika: lozinka: %w", err)
}
if !korisnik.Aktivan {
if err := repo.AzurirajAktivan(ctx, korisnik.ID, true); err != nil {
return fmt.Errorf("ntech: PostaviDemoKorisnika: aktivan: %w", err)
}
}
slog.Info("demo korisnik resetovan", "korisnik", demoIme)
return nil
}
// lokalniKljuceviPodesavanja su ključevi iz tabele podesavanja koji opisuju
// OVU instalaciju/server (izgled login ekrana, adresa fiskalnog uređaja,
// raspored backupa...), a ne podatke firme. Pri uvozu ili vraćanju baze sa
// drugog računara ovi ključevi se NE prepisuju uvezenim vrednostima — server
// zadržava svoje. Sve ostalo u podesavanja smatra se podacima firme i putuje
// sa uvezenom/vraćenom bazom.
var lokalniKljuceviPodesavanja = []string{
"backup_interval_sati",
"backup_broj_kopija",
"login_pozadina",
"login_pozadina_opacity",
"login_pozadina_blur_pozadine",
"login_pozadina_blur_kartice",
"login_pozadina_zatamnjenje_kartice",
"topbar_logo_slika",
"topbar_logo_tekst",
"pfr_url",
"pfr_tip",
"qr_bazni_url",
"verify_host",
}
// ucitajLokalnaPodesavanja čita trenutne vrednosti lokalnih ključeva iz baze
// pre nego što se baza zameni uvozom/vraćanjem backupa.
func ucitajLokalnaPodesavanja(ctx context.Context, db *sql.DB) map[string]string {
vrednosti := make(map[string]string, len(lokalniKljuceviPodesavanja))
for _, kljuc := range lokalniKljuceviPodesavanja {
var vrednost string
if err := db.QueryRowContext(ctx, "SELECT vrednost FROM podesavanja WHERE kljuc = ?", kljuc).Scan(&vrednost); err == nil {
vrednosti[kljuc] = vrednost
}
}
return vrednosti
}
// upisiLokalnaPodesavanja upisuje sačuvane lokalne vrednosti nazad u bazu
// posle zamene, prepisujući preko onoga što je došlo iz uvezene/vraćene baze.
func upisiLokalnaPodesavanja(ctx context.Context, db *sql.DB, vrednosti map[string]string) {
for kljuc, vrednost := range vrednosti {
if _, err := db.ExecContext(ctx,
"INSERT INTO podesavanja (kljuc, vrednost) VALUES (?, ?) ON CONFLICT(kljuc) DO UPDATE SET vrednost = excluded.vrednost",
kljuc, vrednost); err != nil {
slog.Error("upis lokalnih podešavanja: greška", "kljuc", kljuc, "error", err)
}
}
}
// lokalneTabelePodesavanja su tabele koje opisuju podešavanja OVOG servera, a
// ne poslovne podatke. Korisnici NAMERNO nisu ovde: poslovni podaci (servisni
// nalozi, magacinske promene...) referenciraju konkretne korisnike preko ID-a
// (ko je uradio nalog, ko je izvršio promenu), pa moraju putovati zajedno —
// razdvajanje bi ili pokidalo FK ili tiho pripisalo tuđe podatke pogrešnoj
// osobi. Zato korisnici putuju sa uvezenom/vraćenom bazom kao i ostali
// poslovni podaci; samo dozvole (permisije po ulozi, bez FK ka korisnicima)
// ostaju lokalne.
var lokalneTabelePodesavanja = []string{
"dozvole",
}
// volatilneTabeleBezbednosti su sesije i srodno runtime/bezbednosno stanje
// koje referencira korisnike preko FK. Pošto korisnici putuju sa uvezenom
// bazom (vidi lokalneTabelePodesavanja), staro stanje ovih tabela bi posle
// zamene moglo da referencira nepostojeće ili pogrešne korisnike — zato se
// jednostavno brišu (ne vraćaju iz snimka), što tera sve na ponovnu prijavu.
var volatilneTabeleBezbednosti = []string{
"webauthn_kredencijali",
"rezervni_kodovi",
"login_istorija",
"sesije",
"pokusaji_prijave",
}
// ocistiVolatilneTabele briše sesije i srodno bezbednosno stanje posle zamene
// baze — vidi volatilneTabeleBezbednosti.
func ocistiVolatilneTabele(ctx context.Context, db *sql.DB) {
for _, tabela := range volatilneTabeleBezbednosti {
if _, err := db.ExecContext(ctx, "DELETE FROM "+tabela); err != nil {
slog.Error("čišćenje volatilnih tabela: greška", "tabela", tabela, "error", err)
}
}
}
// tabelaSnimak čuva sve redove jedne tabele (generički, bez fiksne šeme) radi
// vraćanja posle zamene baze.
type tabelaSnimak struct {
kolone []string
redovi [][]any
}
// ucitajTabelu učitava sve redove i imena kolona zadate tabele.
func ucitajTabelu(ctx context.Context, db *sql.DB, tabela string) ([]string, [][]any, error) {
rows, err := db.QueryContext(ctx, "SELECT * FROM "+tabela)
if err != nil {
return nil, nil, err
}
defer rows.Close()
kolone, err := rows.Columns()
if err != nil {
return nil, nil, err
}
var redovi [][]any
for rows.Next() {
vrednosti := make([]any, len(kolone))
pokazivaci := make([]any, len(kolone))
for i := range vrednosti {
pokazivaci[i] = &vrednosti[i]
}
if err := rows.Scan(pokazivaci...); err != nil {
return nil, nil, err
}
redovi = append(redovi, vrednosti)
}
return kolone, redovi, rows.Err()
}
// ucitajLokalneTabele čita trenutni sadržaj naloga/bezbednosnih tabela pre
// zamene baze.
func ucitajLokalneTabele(ctx context.Context, db *sql.DB) map[string]tabelaSnimak {
snimci := make(map[string]tabelaSnimak, len(lokalneTabelePodesavanja))
for _, tabela := range lokalneTabelePodesavanja {
kolone, redovi, err := ucitajTabelu(ctx, db, tabela)
if err != nil {
slog.Error("čuvanje lokalnih tabela: greška pri čitanju", "tabela", tabela, "error", err)
continue
}
snimci[tabela] = tabelaSnimak{kolone: kolone, redovi: redovi}
}
return snimci
}
// upisiLokalneTabele vraća sačuvani sadržaj naloga/bezbednosnih tabela posle
// zamene baze, brišući ono što je stiglo iz uvezene/vraćene baze.
func upisiLokalneTabele(ctx context.Context, db *sql.DB, snimci map[string]tabelaSnimak) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
slog.Error("upis lokalnih tabela: greška pri otvaranju transakcije", "error", err)
return
}
defer tx.Rollback()
for i := len(lokalneTabelePodesavanja) - 1; i >= 0; i-- {
tabela := lokalneTabelePodesavanja[i]
if _, err := tx.ExecContext(ctx, "DELETE FROM "+tabela); err != nil {
slog.Error("upis lokalnih tabela: greška pri brisanju", "tabela", tabela, "error", err)
return
}
}
for _, tabela := range lokalneTabelePodesavanja {
snimak, ok := snimci[tabela]
if !ok || len(snimak.redovi) == 0 {
continue
}
upit := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
tabela, strings.Join(snimak.kolone, ", "),
strings.TrimSuffix(strings.Repeat("?, ", len(snimak.kolone)), ", "))
for _, red := range snimak.redovi {
if _, err := tx.ExecContext(ctx, upit, red...); err != nil {
slog.Error("upis lokalnih tabela: greška pri upisu", "tabela", tabela, "error", err)
return
}
}
}
if err := tx.Commit(); err != nil {
slog.Error("upis lokalnih tabela: greška pri komitovanju", "error", err)
}
}
// podrazumevaniUsloviServisa je podrazumevani tekst uslova servisa koji se // podrazumevaniUsloviServisa je podrazumevani tekst uslova servisa koji se
// štampa na reversu; korisnik ga može izmeniti u Podešavanja → Servis. // štampa na reversu; korisnik ga može izmeniti u Podešavanja → Servis.
const podrazumevaniUsloviServisa = `1. Servis ne odgovara za podatke na uređaju. Klijent je dužan da sam napravi rezervnu kopiju podataka pre predaje. const podrazumevaniUsloviServisa = `1. Servis ne odgovara za podatke na uređaju. Klijent je dužan da sam napravi rezervnu kopiju podataka pre predaje.
@@ -215,6 +425,9 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
slog.Error("vrati backup: wal_checkpoint greška", "error", err) slog.Error("vrati backup: wal_checkpoint greška", "error", err)
} }
lokalnaPodesavanja := ucitajLokalnaPodesavanja(r.Context(), h.DB)
lokalneTabele := ucitajLokalneTabele(r.Context(), h.DB)
// Sama zamena baze (zatvaranje stare, kopiranje, otvaranje nove) radi se u // Sama zamena baze (zatvaranje stare, kopiranje, otvaranje nove) radi se u
// zasebnoj gorutini pod EKSKLUZIVNIM zaključavanjem (h.mu.Lock). Razlog: ovaj // zasebnoj gorutini pod EKSKLUZIVNIM zaključavanjem (h.mu.Lock). Razlog: ovaj
// zahtev još drži deljeno zaključavanje (ZakljucajCitanje middleware), pa bi // zahtev još drži deljeno zaključavanje (ZakljucajCitanje middleware), pa bi
@@ -242,7 +455,15 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
slog.Error("vrati backup: greška pri otvaranju nove baze (potreban restart)", "error", err) slog.Error("vrati backup: greška pri otvaranju nove baze (potreban restart)", "error", err)
return return
} }
upisiLokalnaPodesavanja(context.Background(), novaDB, lokalnaPodesavanja)
upisiLokalneTabele(context.Background(), novaDB, lokalneTabele)
ocistiVolatilneTabele(context.Background(), novaDB)
h.reinicijalizujRepozitorijume(novaDB) h.reinicijalizujRepozitorijume(novaDB)
if h.JelDemo {
if err := PostaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil {
slog.Error("vrati backup: greška pri resetovanju demo korisnika", "error", err)
}
}
slog.Info("baza uspešno obnovljena", "izvor", ime) slog.Info("baza uspešno obnovljena", "izvor", ime)
}() }()
@@ -267,6 +488,105 @@ func kopiraFajl(izvor, odrediste string) error {
return err return err
} }
// UvezizBazu zamenjuje trenutnu bazu fajlom koji korisnik otpremi sa svog računara
func (h *Handler) UvezizBazu(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "backup.pokreni"); !ok {
return
}
const maxVelicinaUvoza = 500 << 20 // 500 MB
r.Body = http.MaxBytesReader(w, r.Body, maxVelicinaUvoza)
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+je+prevelik+ili+zahtev+nije+ispravan", http.StatusSeeOther)
return
}
fajl, _, err := r.FormFile("baza")
if err != nil {
http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+nije+izabran", http.StatusSeeOther)
return
}
defer fajl.Close()
zaglavlje := make([]byte, 16)
if _, err := io.ReadFull(fajl, zaglavlje); err != nil || string(zaglavlje) != "SQLite format 3\x00" {
http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+nije+ispravna+SQLite+baza", http.StatusSeeOther)
return
}
privremena := filepath.Join("backups", fmt.Sprintf("uvoz_%s.db", time.Now().Format("20060102_150405")))
odrediste, err := os.Create(privremena)
if err != nil {
slog.Error("uvezi bazu: greška pri kreiranju privremenog fajla", "error", err)
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther)
return
}
if _, err := odrediste.Write(zaglavlje); err != nil {
odrediste.Close()
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther)
return
}
if _, err := io.Copy(odrediste, fajl); err != nil {
odrediste.Close()
slog.Error("uvezi bazu: greška pri čuvanju otpremljenog fajla", "error", err)
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther)
return
}
odrediste.Close()
// pre uvoza, sačuvaj trenutno stanje baze
sigurnosni := filepath.Join("backups", fmt.Sprintf("ntech_%s_pred_uvozom.db", time.Now().Format("20060102_150405")))
if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", sigurnosni); err != nil {
slog.Error("uvezi bazu: greška pri kreiranju sigurnosne kopije", "error", err)
os.Remove(privremena)
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+kreiranju+sigurnosne+kopije", http.StatusSeeOther)
return
}
// isprazni WAL u glavni fajl
if _, err := h.DB.ExecContext(r.Context(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
slog.Error("uvezi bazu: wal_checkpoint greška", "error", err)
}
lokalnaPodesavanja := ucitajLokalnaPodesavanja(r.Context(), h.DB)
lokalneTabele := ucitajLokalneTabele(r.Context(), h.DB)
// zamena baze radi se pod ekskluzivnim zaključavanjem — isti obrazac kao VratiBackup
go func() {
h.mu.Lock()
defer h.mu.Unlock()
defer os.Remove(privremena)
if err := h.DB.Close(); err != nil {
slog.Error("uvezi bazu: greška pri zatvaranju baze", "error", err)
}
if err := kopiraFajl(privremena, h.PutanjaBaze); err != nil {
slog.Error("uvezi bazu: greška pri kopiranju (baza je zatvorena, potreban restart)", "error", err)
return
}
os.Remove(h.PutanjaBaze + "-wal")
os.Remove(h.PutanjaBaze + "-shm")
novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze)
if err != nil {
slog.Error("uvezi bazu: greška pri otvaranju nove baze (potreban restart)", "error", err)
return
}
upisiLokalnaPodesavanja(context.Background(), novaDB, lokalnaPodesavanja)
upisiLokalneTabele(context.Background(), novaDB, lokalneTabele)
ocistiVolatilneTabele(context.Background(), novaDB)
h.reinicijalizujRepozitorijume(novaDB)
if h.JelDemo {
if err := PostaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil {
slog.Error("uvezi bazu: greška pri resetovanju demo korisnika", "error", err)
}
}
slog.Info("baza uspešno uvezena")
}()
http.Redirect(w, r, "/podesavanja?sacuvano=vraceno", http.StatusSeeOther)
}
// validirajMaticniBroj proverava da li uneti matični broj ima tačno 8 cifara. // validirajMaticniBroj proverava da li uneti matični broj ima tačno 8 cifara.
// Prazan broj je dozvoljen (polje nije obavezno). Vraća srpsku poruku o grešci // Prazan broj je dozvoljen (polje nije obavezno). Vraća srpsku poruku o grešci
// ili prazan string ako je broj ispravan. // ili prazan string ako je broj ispravan.
+15 -5
View File
@@ -14,7 +14,7 @@
<div class="kolona" style="gap:16px;"> <div class="kolona" style="gap:16px;">
<!-- lista korisnika --> <!-- lista korisnika -->
<div class="kartica animiraj" style="padding:0;overflow:hidden;"> <div id="lista-korisnika-kartica" class="kartica animiraj" style="padding:0;overflow:hidden;">
<div style="padding:16px 20px;border-bottom:0.5px solid var(--ivica);"> <div style="padding:16px 20px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Korisnici sistema</span> <span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Korisnici sistema</span>
</div> </div>
@@ -81,10 +81,10 @@
</button> </button>
</form> </form>
{{if eq .Uloga "radnik"}} {{if eq .Uloga "radnik"}}
<form method="POST" action="/admin/korisnici/{{.ID}}/obrisi" style="display:inline;" <form method="POST" action="/admin/korisnici/{{.ID}}/obrisi" style="display:inline;">
onsubmit="return confirm('Obrisati korisnika {{.KorisnickoIme}}? Ova radnja je trajna.')">
<button type="submit" <button type="submit"
class="btn-obrisi-ghost"> class="btn-obrisi-ghost"
data-potvrda="Obrisati korisnika {{.KorisnickoIme}}? Ova radnja je trajna.">
Obriši Obriši
</button> </button>
</form> </form>
@@ -103,8 +103,18 @@
<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);"> <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);">
Novi korisnik Novi korisnik
</div> </div>
<form method="POST" action="/admin/korisnici/novi"> <form method="POST" action="/admin/korisnici/novi" data-refresh="#lista-korisnika-kartica">
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:14px;margin-bottom:16px;"> <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:14px;margin-bottom:16px;">
<div>
<label class="polje-labela">Ime</label>
<input type="text" name="ime" required autocomplete="off"
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">Prezime</label>
<input type="text" name="prezime" required autocomplete="off"
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
</div>
<div> <div>
<label class="polje-labela">Korisničko ime</label> <label class="polje-labela">Korisničko ime</label>
<input type="text" name="korisnicko_ime" required minlength="3" autocomplete="off" <input type="text" name="korisnicko_ime" required minlength="3" autocomplete="off"
+4 -4
View File
@@ -88,8 +88,8 @@
<div style="font-size: 14px; color: #16a34a; margin-bottom: 4px; font-weight: 500">✓ Dvostepena verifikacija je uključena</div> <div style="font-size: 14px; color: #16a34a; margin-bottom: 4px; font-weight: 500">✓ Dvostepena verifikacija je uključena</div>
<div style="font-size: 13px; color: var(--tekst-sporedni)">Prijava zahteva TOTP kod pored lozinke.</div> <div style="font-size: 13px; color: var(--tekst-sporedni)">Prijava zahteva TOTP kod pored lozinke.</div>
</div> </div>
<form method="POST" action="/admin/profil/totp/deaktiviraj" onsubmit="return confirm('Da li ste sigurni? Ovo će isključiti dvostepenu verifikaciju.');"> <form method="POST" action="/admin/profil/totp/deaktiviraj">
<button type="submit" class="btn-opasno">Deaktiviraj 2FA</button> <button type="submit" class="btn-opasno" data-potvrda="Da li ste sigurni? Ovo će isključiti dvostepenu verifikaciju.">Deaktiviraj 2FA</button>
</form> </form>
</div> </div>
@@ -105,8 +105,8 @@
{{ else }} {{ else }}
<div style="font-size: 13px; color: var(--tekst-sporedni); margin-bottom: 10px">Rezervni kodovi: preostalo <strong>{{ .BrojRezervnih }}</strong>. Koriste se za prijavu kada nemate pristup aplikaciji sa kodovima.</div> <div style="font-size: 13px; color: var(--tekst-sporedni); margin-bottom: 10px">Rezervni kodovi: preostalo <strong>{{ .BrojRezervnih }}</strong>. Koriste se za prijavu kada nemate pristup aplikaciji sa kodovima.</div>
{{ end }} {{ end }}
<form method="POST" action="/admin/profil/totp/kodovi" onsubmit="return confirm('Generisati nove rezervne kodove? Stari kodovi više neće važiti.');"> <form method="POST" action="/admin/profil/totp/kodovi">
<button type="submit" class="btn-sekundarno">Regeneriši rezervne kodove</button> <button type="submit" class="btn-sekundarno" data-potvrda="Generisati nove rezervne kodove? Stari kodovi više neće važiti.">Regeneriši rezervne kodove</button>
</form> </form>
</div> </div>
+3 -3
View File
@@ -30,11 +30,11 @@
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.PoslednjiPokusaj.Format "02.01.2006. 15:04:05"}}</td> <td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.PoslednjiPokusaj.Format "02.01.2006. 15:04:05"}}</td>
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni)">{{.Preostalo}}</td> <td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni)">{{.Preostalo}}</td>
<td style="padding: 10px 20px; text-align: right"> <td style="padding: 10px 20px; text-align: right">
<form method="POST" action="/admin/blokirane-ip/odblokiraj" style="display:inline;" data-full-reload <form method="POST" action="/admin/blokirane-ip/odblokiraj" style="display:inline;" data-full-reload>
onsubmit="return confirm('Odblokirati IP adresu {{.IP}}? Ovo odmah briše sve neuspele pokušaje prijave za nju.')">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}"> <input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<input type="hidden" name="ip" value="{{.IP}}"> <input type="hidden" name="ip" value="{{.IP}}">
<button type="submit" class="btn-sekundarno" style="font-size: 13px; padding: 6px 14px">Odblokiraj</button> <button type="submit" class="btn-sekundarno" style="font-size: 13px; padding: 6px 14px"
data-potvrda="Odblokirati IP adresu {{.IP}}? Ovo odmah briše sve neuspele pokušaje prijave za nju.">Odblokiraj</button>
</form> </form>
</td> </td>
</tr> </tr>
+3 -3
View File
@@ -93,10 +93,10 @@
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a> <a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
{{if index .Dozvole "fiskal.zakljucenje"}} {{if index .Dozvole "fiskal.zakljucenje"}}
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload <form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload>
onsubmit="return confirm('Resetovati brojače na ekranu? Ovo NIJE zakonska obaveza — izveštaj po periodu radi nezavisno od ovoga. Samo svodi prikaz Promet od poslednjeg preseka na nulu.')">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}"> <input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;color:var(--tekst-sporedni);">Resetuj brojače (opciono)</button> <button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;color:var(--tekst-sporedni);"
data-potvrda="Resetovati brojače na ekranu? Ovo NIJE zakonska obaveza — izveštaj po periodu radi nezavisno od ovoga. Samo svodi prikaz Promet od poslednjeg preseka na nulu.">Resetuj brojače (opciono)</button>
</form> </form>
{{end}} {{end}}
</div> </div>
+15
View File
@@ -25,12 +25,27 @@
{{if .Obrisana}} {{if .Obrisana}}
<div class="poruka-uspeh poruka-animacija">Kategorija je uspešno obrisana.</div> <div class="poruka-uspeh poruka-animacija">Kategorija je uspešno obrisana.</div>
{{end}} {{end}}
{{if .PrikaziSifreDod}}
<div class="poruka-uspeh poruka-animacija">
{{if eq .SifreDodeljene 0}}Svi artikli već imaju šifru.{{else}}Dodeljena šifra za {{.SifreDodeljene}} artikal(a).{{end}}
</div>
{{end}}
<!-- nazad dugme --> <!-- nazad dugme -->
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<a href="/magacin" class="nazad-link" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML"> <a href="/magacin" class="nazad-link" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
<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> <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 magacin Nazad na magacin
</a> </a>
{{if index $.Dozvole "artikal.izmeni"}}
<form method="POST" action="/magacin/kategorije/dodeli-sifre">
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;"
data-potvrda="Proći kroz sve artikle bez šifre i dodeliti im šifru na osnovu prefiksa njihove kategorije?">
Dodeli šifre artiklima bez šifre
</button>
</form>
{{end}}
</div>
{{if index $.Dozvole "kategorija.dodaj"}} {{if index $.Dozvole "kategorija.dodaj"}}
<!-- forma za novu kategoriju --> <!-- forma za novu kategoriju -->
+1 -1
View File
@@ -30,7 +30,7 @@
<div> <div>
<label class="polje-labela">Šifra artikla</label> <label class="polje-labela">Šifra artikla</label>
<input type="text" name="sifra" id="sifra-input" value="{{.Artikal.Sifra}}" <input type="text" name="sifra" id="sifra-input" value="{{.Artikal.Sifra}}"
placeholder="npr. KOMP-0001" placeholder="{{if .PredlogSifre}}{{.PredlogSifre}}{{else}}npr. KOMP-0001{{end}}"
style="width:100%;font-family:monospace;"> style="width:100%;font-family:monospace;">
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Ako ostaviš prazno, šifra se automatski dodeljuje.</div> <div style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Ako ostaviš prazno, šifra se automatski dodeljuje.</div>
</div> </div>
+3 -3
View File
@@ -303,11 +303,11 @@
<td style="padding:8px 12px;color:var(--tekst-sporedni);">{{.Datum}}</td> <td style="padding:8px 12px;color:var(--tekst-sporedni);">{{.Datum}}</td>
<td style="padding:8px 12px;text-align:right;color:var(--tekst-sporedni);">{{.Velicina}}</td> <td style="padding:8px 12px;text-align:right;color:var(--tekst-sporedni);">{{.Velicina}}</td>
<td style="padding:8px 12px;text-align:right;"> <td style="padding:8px 12px;text-align:right;">
<form method="POST" action="/podesavanja/backup/vrati" <form method="POST" action="/podesavanja/backup/vrati">
onsubmit="return confirm('Vratiti bazu na stanje od: {{.Datum}}?\n\nTrenutna baza će biti automatski sačuvana pre obnove.')">
<input type="hidden" name="ime" value="{{.Ime}}"> <input type="hidden" name="ime" value="{{.Ime}}">
<button type="submit" <button type="submit"
class="btn-obrisi-ghost"> class="btn-obrisi-ghost"
data-potvrda="Vratiti bazu na stanje od: {{.Datum}}? Trenutna baza će biti automatski sačuvana pre obnove.">
Vrati Vrati
</button> </button>
</form> </form>
+13 -3
View File
@@ -26,6 +26,16 @@
<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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><polyline points="7 5 12 10 17 5"/></svg> <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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><polyline points="7 5 12 10 17 5"/></svg>
Vrati backup baze Vrati backup baze
</button> </button>
<button type="button" onclick="document.getElementById('uvezi-bazu-input').click()"
style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:var(--kartica);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;transition:background 0.2s;"
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
<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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Uvezi bazu
</button>
<form id="uvezi-bazu-forma" method="POST" action="/podesavanja/uvezi-bazu" enctype="multipart/form-data" style="display:none;">
<input type="file" id="uvezi-bazu-input" name="baza" accept=".db" required
onchange="window._ntechPotvrdi('Uvoz će zameniti CELU trenutnu bazu podataka fajlom koji ste izabrali.\n\nTrenutna baza će automatski biti sačuvana pre zamene.\n\nNastaviti?').then((ok) => { if (ok) { document.getElementById('uvezi-bazu-forma').requestSubmit(); } else { this.value = ''; } })">
</form>
</div> </div>
</div> </div>
@@ -77,11 +87,11 @@
<td style="padding:8px 12px;color:var(--tekst-sporedni);">{{.Datum}}</td> <td style="padding:8px 12px;color:var(--tekst-sporedni);">{{.Datum}}</td>
<td style="padding:8px 12px;text-align:right;color:var(--tekst-sporedni);">{{.Velicina}}</td> <td style="padding:8px 12px;text-align:right;color:var(--tekst-sporedni);">{{.Velicina}}</td>
<td style="padding:8px 12px;text-align:right;"> <td style="padding:8px 12px;text-align:right;">
<form method="POST" action="/podesavanja/backup/vrati" <form method="POST" action="/podesavanja/backup/vrati">
onsubmit="return confirm('Vratiti bazu na stanje od: {{.Datum}}?\n\nTrenutna baza će biti automatski sačuvana pre obnove.')">
<input type="hidden" name="ime" value="{{.Ime}}"> <input type="hidden" name="ime" value="{{.Ime}}">
<button type="submit" <button type="submit"
class="btn-obrisi-ghost"> class="btn-obrisi-ghost"
data-potvrda="Vratiti bazu na stanje od: {{.Datum}}? Trenutna baza će biti automatski sačuvana pre obnove.">
Vrati Vrati
</button> </button>
</form> </form>