Uvoz/vraćanje baze: čuvanje lokalnih podešavanja i naloga, novi korisnik ime/prezime, custom potvrda umesto confirm(), ispravka prefiksa šifre artikla po kategoriji
This commit is contained in:
+3
-35
@@ -20,7 +20,6 @@ import (
|
||||
"ntech/internal/auth"
|
||||
"ntech/internal/be"
|
||||
"ntech/internal/config"
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/handler"
|
||||
ntechmw "ntech/internal/middleware"
|
||||
@@ -152,7 +151,7 @@ func main() {
|
||||
h.JelDemo = os.Getenv("NTECH_ENV") == "demo"
|
||||
if h.JelDemo {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -338,6 +337,7 @@ func main() {
|
||||
|
||||
r.Get("/podesavanja/backup", h.BackupBaze)
|
||||
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" 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.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju)
|
||||
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/nova", h.NovaNabavka)
|
||||
r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku)
|
||||
@@ -556,39 +557,6 @@ func ucitajTotpKljuc(envFajl string) ([]byte, error) {
|
||||
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.
|
||||
// Koristi već otvorenu vezu ka bazi (VACUUM INTO je bezbedan na pooled konekciji).
|
||||
func napraviBackup(db *sql.DB, putanjaBaze string, maxKopija int) {
|
||||
|
||||
@@ -27,6 +27,8 @@ type ArtikalRepository interface {
|
||||
Izmeni(ctx context.Context, a *model.Artikal) error
|
||||
// AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci)
|
||||
AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error
|
||||
// AzurirajSifru menja samo šifru artikla
|
||||
AzurirajSifru(ctx context.Context, id int64, sifra string) error
|
||||
PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
// Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju)
|
||||
|
||||
@@ -304,6 +304,15 @@ func (r *ArtikalRepo) SledecaSifra(ctx context.Context, kategorijaID *int64) (st
|
||||
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).
|
||||
func (r *ArtikalRepo) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ntech/internal/auth"
|
||||
"ntech/internal/db/sqlite"
|
||||
@@ -104,13 +105,15 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
ime := r.FormValue("korisnicko_ime")
|
||||
korisnickoIme := r.FormValue("korisnicko_ime")
|
||||
lozinka := r.FormValue("lozinka")
|
||||
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
|
||||
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.")
|
||||
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
|
||||
return
|
||||
@@ -123,7 +126,13 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
|
||||
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.")
|
||||
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
|
||||
return
|
||||
|
||||
@@ -17,9 +17,11 @@ import (
|
||||
// PodaciKategorija su podaci za stranicu kategorija
|
||||
type PodaciKategorija struct {
|
||||
model.PodaciStranice
|
||||
Kategorije []model.Kategorija
|
||||
Sacuvano bool
|
||||
Obrisana bool
|
||||
Kategorije []model.Kategorija
|
||||
Sacuvano bool
|
||||
Obrisana bool
|
||||
SifreDodeljene int
|
||||
PrikaziSifreDod bool
|
||||
}
|
||||
|
||||
// Kategorije renderuje listu kategorija
|
||||
@@ -39,16 +41,53 @@ func (h *Handler) Kategorije(w http.ResponseWriter, r *http.Request) {
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Kategorije"
|
||||
sifreDodeljeneStr := r.URL.Query().Get("sifre_dodeljene")
|
||||
sifreDodeljene, _ := strconv.Atoi(sifreDodeljeneStr)
|
||||
podaci := PodaciKategorija{
|
||||
PodaciStranice: ps,
|
||||
Kategorije: kategorije,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
Obrisana: r.URL.Query().Get("obrisana") == "1",
|
||||
PodaciStranice: ps,
|
||||
Kategorije: kategorije,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
Obrisana: r.URL.Query().Get("obrisana") == "1",
|
||||
SifreDodeljene: sifreDodeljene,
|
||||
PrikaziSifreDod: sifreDodeljeneStr != "",
|
||||
}
|
||||
|
||||
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
|
||||
func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok {
|
||||
|
||||
@@ -23,8 +23,11 @@ type PodaciFormeArtikla struct {
|
||||
KategorijaIDStr string
|
||||
Dobavljaci []model.Dobavljac // svi dobavljači za izbor
|
||||
IzabraniDobavljaci map[int64]bool // dobavljači vezani za artikal (za checked stanje)
|
||||
Greska string
|
||||
Izmena bool
|
||||
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
|
||||
Izmena bool
|
||||
}
|
||||
|
||||
// NoviArtikal prikazuje formu za unos novog artikla
|
||||
@@ -62,7 +65,8 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
PodaciStranice: ps,
|
||||
Kategorije: kategorije,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
@@ -20,11 +22,219 @@ import (
|
||||
|
||||
"encoding/json"
|
||||
|
||||
"ntech/internal/auth"
|
||||
"ntech/internal/db"
|
||||
ntechsqlite "ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"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
|
||||
// š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.
|
||||
@@ -215,6 +425,9 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// zasebnoj gorutini pod EKSKLUZIVNIM zaključavanjem (h.mu.Lock). Razlog: ovaj
|
||||
// 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)
|
||||
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("vrati backup: greška pri resetovanju demo korisnika", "error", err)
|
||||
}
|
||||
}
|
||||
slog.Info("baza uspešno obnovljena", "izvor", ime)
|
||||
}()
|
||||
|
||||
@@ -267,6 +488,105 @@ func kopiraFajl(izvor, odrediste string) error {
|
||||
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.
|
||||
// Prazan broj je dozvoljen (polje nije obavezno). Vraća srpsku poruku o grešci
|
||||
// ili prazan string ako je broj ispravan.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="kolona" style="gap:16px;">
|
||||
|
||||
<!-- 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);">
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Korisnici sistema</span>
|
||||
</div>
|
||||
@@ -81,10 +81,10 @@
|
||||
</button>
|
||||
</form>
|
||||
{{if eq .Uloga "radnik"}}
|
||||
<form method="POST" action="/admin/korisnici/{{.ID}}/obrisi" style="display:inline;"
|
||||
onsubmit="return confirm('Obrisati korisnika {{.KorisnickoIme}}? Ova radnja je trajna.')">
|
||||
<form method="POST" action="/admin/korisnici/{{.ID}}/obrisi" style="display:inline;">
|
||||
<button type="submit"
|
||||
class="btn-obrisi-ghost">
|
||||
class="btn-obrisi-ghost"
|
||||
data-potvrda="Obrisati korisnika {{.KorisnickoIme}}? Ova radnja je trajna.">
|
||||
Obriši
|
||||
</button>
|
||||
</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);">
|
||||
Novi korisnik
|
||||
</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>
|
||||
<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>
|
||||
<label class="polje-labela">Korisničko ime</label>
|
||||
<input type="text" name="korisnicko_ime" required minlength="3" autocomplete="off"
|
||||
|
||||
@@ -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: 13px; color: var(--tekst-sporedni)">Prijava zahteva TOTP kod pored lozinke.</div>
|
||||
</div>
|
||||
<form method="POST" action="/admin/profil/totp/deaktiviraj" onsubmit="return confirm('Da li ste sigurni? Ovo će isključiti dvostepenu verifikaciju.');">
|
||||
<button type="submit" class="btn-opasno">Deaktiviraj 2FA</button>
|
||||
<form method="POST" action="/admin/profil/totp/deaktiviraj">
|
||||
<button type="submit" class="btn-opasno" data-potvrda="Da li ste sigurni? Ovo će isključiti dvostepenu verifikaciju.">Deaktiviraj 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -105,8 +105,8 @@
|
||||
{{ 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>
|
||||
{{ end }}
|
||||
<form method="POST" action="/admin/profil/totp/kodovi" onsubmit="return confirm('Generisati nove rezervne kodove? Stari kodovi više neće važiti.');">
|
||||
<button type="submit" class="btn-sekundarno">Regeneriši rezervne kodove</button>
|
||||
<form method="POST" action="/admin/profil/totp/kodovi">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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)">{{.Preostalo}}</td>
|
||||
<td style="padding: 10px 20px; text-align: right">
|
||||
<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.')">
|
||||
<form method="POST" action="/admin/blokirane-ip/odblokiraj" style="display:inline;" data-full-reload>
|
||||
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{{if index .Dozvole "fiskal.zakljucenje"}}
|
||||
<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.')">
|
||||
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload>
|
||||
<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>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -25,12 +25,27 @@
|
||||
{{if .Obrisana}}
|
||||
<div class="poruka-uspeh poruka-animacija">Kategorija je uspešno obrisana.</div>
|
||||
{{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 -->
|
||||
<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>
|
||||
Nazad na magacin
|
||||
</a>
|
||||
<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">
|
||||
<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
|
||||
</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"}}
|
||||
<!-- forma za novu kategoriju -->
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<div>
|
||||
<label class="polje-labela">Šifra artikla</label>
|
||||
<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;">
|
||||
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Ako ostaviš prazno, šifra se automatski dodeljuje.</div>
|
||||
</div>
|
||||
|
||||
@@ -303,11 +303,11 @@
|
||||
<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;">
|
||||
<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.')">
|
||||
<form method="POST" action="/podesavanja/backup/vrati">
|
||||
<input type="hidden" name="ime" value="{{.Ime}}">
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -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>
|
||||
Vrati backup baze
|
||||
</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>
|
||||
|
||||
@@ -77,11 +87,11 @@
|
||||
<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;">
|
||||
<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.')">
|
||||
<form method="POST" action="/podesavanja/backup/vrati">
|
||||
<input type="hidden" name="ime" value="{{.Ime}}">
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user