diff --git a/assets.go b/assets.go index 2927ada..f95524f 100644 --- a/assets.go +++ b/assets.go @@ -5,7 +5,7 @@ import "embed" //go:embed migrations var MigracijeFS embed.FS -//go:embed web/static/css +//go:embed web/static var StaticFS embed.FS //go:embed web/templates diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index d6061d2..39a8f7e 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -2,13 +2,16 @@ package main import ( "context" + "database/sql" "fmt" "io/fs" "log" "net/http" "os" + "mime" "path/filepath" "sort" + "strconv" "time" "ntech" @@ -27,6 +30,8 @@ import ( var Verzija = "dev" func main() { + mime.AddExtensionType(".js", "text/javascript") + mime.AddExtensionType(".css", "text/css") godotenv.Load("ntech.env") auth.InitAuthLog() @@ -80,7 +85,24 @@ func main() { log.Printf("Upozorenje: greška pri inicijalizaciji dozvola: %v", err) } - napraviStartupBackup(putanjaBaze) + // ukloni zastarele dozvole (siročiće) koje više ne postoje u kodu + if br, err := sqlite.OcistiSirociceDoz(context.Background(), db, ntechmw.SveAkcije()); err != nil { + log.Printf("Upozorenje: greška pri čišćenju dozvola: %v", err) + } else if br > 0 { + log.Printf("Dozvole: uklonjeno %d zastarelih redova", br) + } + + napraviBackup(db, putanjaBaze) + + // periodični automatski backup — interval se čita iz podešavanja u svakom ciklusu, + // tako da izmena u podešavanjima stupa na snagu bez restarta (od sledećeg ciklusa) + go func() { + for { + sati := procitajIntPodesavanje(db, "backup_interval_sati", 24) + time.Sleep(time.Duration(sati) * time.Hour) + napraviBackup(db, putanjaBaze) + } + }() // periodično brisanje isteklih sesija i starih pokušaja prijave go func() { @@ -164,6 +186,7 @@ func main() { r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal) r.Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) r.Get("/magacin/obrisi/{id}", h.ObrisiArtikal) + r.Post("/magacin/premesti/{id}", h.PremestiArtikal) r.Get("/magacin/kategorije", h.Kategorije) r.Post("/magacin/kategorije/dodaj", h.DodajKategoriju) r.Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) @@ -250,8 +273,9 @@ func main() { } } -// napraviStartupBackup kreira kopiju baze pri pokretanju i čuva poslednjih 7 -func napraviStartupBackup(putanjaBaze string) { +// 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) { if _, err := os.Stat(putanjaBaze); os.IsNotExist(err) { return } @@ -265,20 +289,27 @@ func napraviStartupBackup(putanjaBaze string) { ime := fmt.Sprintf("ntech_%s.db", time.Now().Format("20060102_150405")) odrediste := filepath.Join(folder, ime) - db, err := sqlite.OtvoriDB(putanjaBaze) - if err != nil { - log.Printf("backup: ne mogu otvoriti bazu: %v", err) - return - } - defer db.Close() - if _, err := db.ExecContext(context.Background(), "VACUUM INTO ?", odrediste); err != nil { log.Printf("backup: greška pri pravljenju backup-a: %v", err) return } log.Printf("Backup kreiran: %s", odrediste) - ocistiStareBackupe(folder, 7) + ocistiStareBackupe(folder, procitajIntPodesavanje(db, "backup_broj_kopija", 7)) +} + +// procitajIntPodesavanje vraća celobrojnu vrednost podešavanja iz baze, +// ili podrazumevanu ako ključ ne postoji ili nije validan pozitivan broj +func procitajIntPodesavanje(db *sql.DB, kljuc string, podrazumevano int) int { + v, err := sqlite.DohvatiPodesavanje(context.Background(), db, kljuc) + if err != nil || v == "" { + return podrazumevano + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + return podrazumevano + } + return n } // ocistiStareBackupe briše najstarije backup fajlove ako ih ima više od max diff --git a/internal/db/repository.go b/internal/db/repository.go index 8c2fad0..021a0c6 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -13,6 +13,7 @@ type ArtikalRepository interface { DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) Izmeni(ctx context.Context, a *model.Artikal) error + PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error Obrisi(ctx context.Context, id int64) error } diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index 4466451..a324a40 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -147,6 +147,17 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error { return nil } +// PremestiKategoriju menja samo kategoriju artikla (premeštanje u drugu kategoriju). +// kategorijaID može biti nil — tada artikal ostaje bez kategorije. +func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error { + _, err := r.db.ExecContext(ctx, + "UPDATE artikli SET kategorija_id = ? WHERE id = ?", kategorijaID, id) + if err != nil { + return fmt.Errorf("ntech: ArtikalRepo.PremestiKategoriju: %w", err) + } + return nil +} + // Obrisi briše artikal po ID-u func (r *ArtikalRepo) Obrisi(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, "DELETE FROM artikli WHERE id = ?", id) diff --git a/internal/db/sqlite/dozvole.go b/internal/db/sqlite/dozvole.go index 4e3d754..22854c3 100644 --- a/internal/db/sqlite/dozvole.go +++ b/internal/db/sqlite/dozvole.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "strings" ) type sqliteDozvoleRepo struct { @@ -27,6 +28,28 @@ func InicijalizujDozvole(ctx context.Context, db *sql.DB, defaultFn func(uloga, return popuniPodrazumevano(ctx, db, defaultFn, sveAkcije) } +// OcistiSirociceDoz briše iz tabele dozvola redove čija akcija više ne postoji +// u sveAkcije (npr. nakon uklanjanja zastarele dozvole iz koda). Vraća broj obrisanih. +func OcistiSirociceDoz(ctx context.Context, db *sql.DB, sveAkcije []string) (int64, error) { + if len(sveAkcije) == 0 { + // sigurnosna brana — bez liste bismo obrisali sve, što ne želimo + return 0, nil + } + placeholders := strings.Repeat("?,", len(sveAkcije)) + placeholders = strings.TrimSuffix(placeholders, ",") + args := make([]any, len(sveAkcije)) + for i, a := range sveAkcije { + args[i] = a + } + rezultat, err := db.ExecContext(ctx, + `DELETE FROM dozvole WHERE akcija NOT IN (`+placeholders+`)`, args...) + if err != nil { + return 0, fmt.Errorf("ntech: dozvole.OcistiSirocice: %w", err) + } + br, _ := rezultat.RowsAffected() + return br, nil +} + // popuniPodrazumevano upisuje sve podrazumevane dozvole u bazu func popuniPodrazumevano(ctx context.Context, db *sql.DB, defaultFn func(uloga, akcija string) bool, sveAkcije []string) error { for _, uloga := range []string{"radnik", "admin", "superadmin"} { diff --git a/internal/handler/dobavljac.go b/internal/handler/dobavljac.go index 33df8da..d201773 100644 --- a/internal/handler/dobavljac.go +++ b/internal/handler/dobavljac.go @@ -30,6 +30,11 @@ type PodaciFormeDobavljaca struct { // Dobavljaci renderuje listu svih dobavljača func (h *Handler) Dobavljaci(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "dobavljac.pregled") { + http.Error(w, "Nemate dozvolu za pregled dobavljača.", http.StatusForbidden) + return + } podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 3f36a22..cf64443 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -16,7 +16,7 @@ var bazniSabloni = []string{ // saSidebar su šabloni koji koriste base layout (sidebar + topbar) var saSidebar = []string{ - "admin_korisnici", "admin_profil", "admin_login_istorija", + "admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", "dashboard", "dobavljaci", "dobavljac_forma", "izvestaji", diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index ec2d88b..808aec9 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -31,6 +31,11 @@ type PodaciFormeKlijenta struct { // Klijenti renderuje listu svih klijenata sa opcionom pretragom func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "klijent.pregled") { + http.Error(w, "Nemate dozvolu za pregled klijenata.", http.StatusForbidden) + return + } podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index 4b8692a..ac82c2b 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -21,6 +21,7 @@ type PodaciMagacina struct { KategorijaIDStr string Sacuvano bool Obrisan bool + Premesten bool } // Magacin renderuje listu artikala @@ -68,11 +69,44 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { KategorijaIDStr: katIDStr, Sacuvano: r.URL.Query().Get("sacuvano") == "1", Obrisan: r.URL.Query().Get("obrisan") == "1", + Premesten: r.URL.Query().Get("premesten") == "1", } h.renderujTemplate(w, "magacin", podaci) } +// PremestiArtikal menja kategoriju artikla (premeštanje u drugu kategoriju). +// Prazno polje kategorija_id znači premeštanje u "bez kategorije". +func (h *Handler) PremestiArtikal(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "artikal.premesti") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Neispravan ID artikla", http.StatusBadRequest) + return + } + + var kategorijaID *int64 + if v := r.FormValue("kategorija_id"); v != "" { + kid, err := strconv.ParseInt(v, 10, 64) + if err != nil { + http.Error(w, "Neispravna kategorija", http.StatusBadRequest) + return + } + kategorijaID = &kid + } + + if err := h.Artikli.PremestiKategoriju(r.Context(), id, kategorijaID); err != nil { + http.Error(w, "Greška pri premeštanju artikla", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/magacin?premesten=1", http.StatusSeeOther) +} + // ObrisiArtikal briše artikal po ID-u func (h *Handler) ObrisiArtikal(w http.ResponseWriter, r *http.Request) { k := middleware.KorisnikIzKonteksta(r.Context()) diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 06cecda..0e2ee1d 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -35,6 +35,8 @@ type PodaciPodesavanja struct { LogoGreska string BackupVracen bool Backupi []BackupInfo + BackupIntervalSati string + BackupBrojKopija string LoginPozadina string LoginPozadinaOpacity string LoginPozadinaBlurPozadine string @@ -87,6 +89,8 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) { LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"), LoginPozadinaBlurKartice: vrednostIliDefault(podesavanja, "login_pozadina_blur_kartice", "12"), LoginPozadinaZatamnjenjeKartice: vrednostIliDefault(podesavanja, "login_pozadina_zatamnjenje_kartice", "0"), + BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"), + BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"), } h.renderujTemplate(w, "podesavanja", podaci) @@ -244,6 +248,18 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { } } + // backup podešavanja — čuvamo samo ako su validni pozitivni brojevi u razumnom opsegu + if v := r.FormValue("backup_interval_sati"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 720 { + _ = ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "backup_interval_sati", strconv.Itoa(n)) + } + } + if v := r.FormValue("backup_broj_kopija"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 100 { + _ = ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "backup_broj_kopija", strconv.Itoa(n)) + } + } + sledeci := r.FormValue("_next") if sledeci == "" || !strings.HasPrefix(sledeci, "/") { sledeci = "/podesavanja" @@ -591,6 +607,8 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"), LoginPozadinaBlurKartice: vrednostIliDefault(podesavanja, "login_pozadina_blur_kartice", "12"), LoginPozadinaZatamnjenjeKartice: vrednostIliDefault(podesavanja, "login_pozadina_zatamnjenje_kartice", "0"), + BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"), + BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"), }, nil } diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 91a2a20..f9f50f4 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -76,6 +76,11 @@ func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS { // Prodaja renderuje listu svih prodajnih naloga func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.pregled") { + http.Error(w, "Nemate dozvolu za pregled prodaje.", http.StatusForbidden) + return + } podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) @@ -203,6 +208,11 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { // DetaljiProdaje prikazuje pregled jednog prodajnog naloga sa svim stavkama func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.pregled") { + http.Error(w, "Nemate dozvolu za pregled prodaje.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) @@ -255,6 +265,11 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) { // StampaProdaje renderuje print-friendly stranicu za dati prodajni nalog func (h *Handler) StampaProdaje(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.pregled") { + http.Error(w, "Nemate dozvolu za pregled prodaje.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) diff --git a/internal/handler/servis.go b/internal/handler/servis.go index efb888e..7c1a1be 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -50,6 +50,11 @@ type PodaciDetaljiNaloga struct { // Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa func (h *Handler) Servis(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.pregled") { + http.Error(w, "Nemate dozvolu za pregled servisnih naloga.", http.StatusForbidden) + return + } podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) @@ -307,6 +312,11 @@ func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) { // DetaljiNaloga prikazuje sve podatke jednog servisnog naloga sa ugrađenim delovima func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.pregled") { + http.Error(w, "Nemate dozvolu za pregled servisnih naloga.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) diff --git a/internal/middleware/dozvole.go b/internal/middleware/dozvole.go index 6becf02..2b19a09 100644 --- a/internal/middleware/dozvole.go +++ b/internal/middleware/dozvole.go @@ -1,15 +1,15 @@ package middleware // sve poznate akcije u sistemu +// Napomena: pregled magacina i podsetnici su namerno javni (bez dozvole) i nisu ovde. +// Upravljanje korisnicima ide preko uloge (RequireAdmin middleware), ne preko dozvole. var sveAkcije = []string{ - "artikal.pregled", "artikal.dodaj", "artikal.izmeni", "artikal.obrisi", "artikal.premesti", "kategorija.pregled", "kategorija.dodaj", - "kategorija.izmeni", "kategorija.obrisi", "nabavka.pregled", "nabavka.dodaj", @@ -25,30 +25,19 @@ var sveAkcije = []string{ "prodaja.pregled", "prodaja.dodaj", "prodaja.obrisi", + "prodaja.storno", "klijent.pregled", "klijent.dodaj", "klijent.izmeni", "klijent.obrisi", - "podsetnik.pregled", - "podsetnik.dodaj", - "podsetnik.izmeni", - "podsetnik.obrisi", "izvestaj.pregled", "podesavanja.pregled", "podesavanja.izmeni", - "korisnik.pregled", - "korisnik.dodaj", - "korisnik.izmeni", - "korisnik.obrisi", - "korisnik.uloga", + "podesavanja.login_pozadina", "backup.pregled", "backup.pokreni", - "podesavanja.login_pozadina", - "podesavanja.app_pozadina", - "tema.globalno", "tema.lokalno", "dashboard.prihod", - "prodaja.storno", } // SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu @@ -65,13 +54,12 @@ func ImaDozvolu(uloga, akcija string) bool { case "admin": switch akcija { - // artikal - case "artikal.pregled", "artikal.dodaj", "artikal.izmeni", + // artikal (pregled magacina je javan — nije dozvola) + case "artikal.dodaj", "artikal.izmeni", "artikal.obrisi", "artikal.premesti": return true // kategorija - case "kategorija.pregled", "kategorija.dodaj", - "kategorija.izmeni", "kategorija.obrisi": + case "kategorija.pregled", "kategorija.dodaj", "kategorija.obrisi": return true // nabavka case "nabavka.pregled", "nabavka.dodaj", "nabavka.obrisi": @@ -91,26 +79,18 @@ func ImaDozvolu(uloga, akcija string) bool { case "klijent.pregled", "klijent.dodaj", "klijent.izmeni", "klijent.obrisi": return true - // podsetnik - case "podsetnik.pregled", "podsetnik.dodaj", - "podsetnik.izmeni", "podsetnik.obrisi": - return true // izveštaji i podešavanja case "izvestaj.pregled", "podesavanja.pregled", "podesavanja.izmeni": return true - // korisnici (bez promene uloge) - case "korisnik.pregled", "korisnik.dodaj", - "korisnik.izmeni", "korisnik.obrisi": - return true // backup case "backup.pregled", "backup.pokreni": return true - // pozadinske slike - case "podesavanja.login_pozadina", "podesavanja.app_pozadina": + // pozadina prijavne stranice + case "podesavanja.login_pozadina": return true - // teme - case "tema.globalno", "tema.lokalno": + // lokalna tema + case "tema.lokalno": return true // dashboard — prihod samo admin+ case "dashboard.prihod": @@ -120,9 +100,6 @@ func ImaDozvolu(uloga, akcija string) bool { case "radnik": switch akcija { - // artikal — samo pregled - case "artikal.pregled": - return true // kategorija — samo pregled case "kategorija.pregled": return true @@ -138,10 +115,6 @@ func ImaDozvolu(uloga, akcija string) bool { // klijent — bez brisanja case "klijent.pregled", "klijent.dodaj", "klijent.izmeni": return true - // podsetnik — sve - case "podsetnik.pregled", "podsetnik.dodaj", - "podsetnik.izmeni", "podsetnik.obrisi": - return true // lokalna tema case "tema.lokalno": return true diff --git a/migrations/037_backup_podesavanja.sql b/migrations/037_backup_podesavanja.sql new file mode 100644 index 0000000..edf1068 --- /dev/null +++ b/migrations/037_backup_podesavanja.sql @@ -0,0 +1,5 @@ +-- Podešavanja za automatski backup baze. +-- backup_interval_sati: na koliko sati se pravi automatska rezervna kopija (uz onu pri pokretanju). +-- backup_broj_kopija: koliko poslednjih kopija se čuva (starije se brišu — rotacija). +INSERT OR IGNORE INTO podesavanja (kljuc, vrednost) VALUES ('backup_interval_sati', '24'); +INSERT OR IGNORE INTO podesavanja (kljuc, vrednost) VALUES ('backup_broj_kopija', '7'); diff --git a/web/static/css/main.css b/web/static/css/main.css index 5cfe4d6..3262f9e 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -219,10 +219,10 @@ body { .nav-podmeni.bez-tranzicije { transition: none !important; } -/* u skupljenom modu podmeni se nikad ne prikazuje */ -.sidebar.skupljen .nav-podmeni { - max-height: 0 !important; - transition: none; +/* u skupljenom modu otvoren podmeni dobija vizuelnu oznaku — pozadina + leva akcenat-linija */ +.sidebar.skupljen .nav-podmeni.otvoren { + background: var(--sb-aktivan); + border-left: 3px solid var(--sb-akcent); } .nav-podstavka { padding-left: 48px !important; diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index ec370e8..b5118bf 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -1,17 +1,25 @@ -// otvara/zatvara podmeni u sidebaru — funkcioniše i posle HTMX swap-a (čisti onclick, bez Alpine-a) +// otvara/zatvara podmeni u sidebaru — radi i kad je sidebar skupljen i kad je proširen +// (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni. function ntechTogglePodmeni(btn) { - var sidebar = document.getElementById('sidebar'); - // ako je sidebar skupljen, raširimo ga pre nego otvorimo podmeni - if (sidebar && sidebar.classList.contains('skupljen')) { - sidebar.classList.remove('skupljen'); - localStorage.setItem('sidebar-skupljeno', 'false'); - } var podmeni = btn.nextElementSibling; if (!podmeni) return; - podmeni.classList.toggle('otvoren'); - var strelicaSvg = btn.querySelector('.nav-strelica svg'); - if (strelicaSvg) { - strelicaSvg.style.transform = podmeni.classList.contains('otvoren') ? 'rotate(180deg)' : 'rotate(0deg)'; + var jeOtvoren = podmeni.classList.contains('otvoren'); + + // zatvori sve podmenije i vrati njihove strelice — međusobna isključivost + document.querySelectorAll('#sidebar .nav-podmeni').forEach(function(el) { + el.classList.remove('otvoren'); + var dugme = el.previousElementSibling; + if (dugme) { + var s = dugme.querySelector('.nav-strelica svg'); + if (s) s.style.transform = 'rotate(0deg)'; + } + }); + + // ako kliknuti nije već bio otvoren — otvori ga + if (!jeOtvoren) { + podmeni.classList.add('otvoren'); + var svg = btn.querySelector('.nav-strelica svg'); + if (svg) svg.style.transform = 'rotate(180deg)'; } } @@ -248,5 +256,4 @@ document.addEventListener('alpine:init', () => { // za prvo učitavanje (defer script) — sprečava animaciju podmenia koji su inicijalno otvoreni ntechInicijalizujPodmeni() -// dodaje klik listenere na podmeni dugmad (data-podmeni-dugme) ntechDodajPodmeniListenere() diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index e51b6e6..a2bfd80 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -47,23 +47,29 @@ {{end}} + {{if index .Dozvole "servis.pregled"}} Servis Servis + {{end}} + {{if index .Dozvole "prodaja.pregled"}} Prodaja Prodaja + {{end}} + {{if index .Dozvole "klijent.pregled"}} Klijenti Klijenti + {{end}} @@ -71,11 +77,13 @@ Podsetnici + {{if index .Dozvole "dobavljac.pregled"}} Dobavljači Dobavljači + {{end}} {{if index .Dozvole "izvestaj.pregled"}} @@ -105,7 +113,7 @@ - {{end}} diff --git a/web/templates/stranice/admin_dozvole.html b/web/templates/stranice/admin_dozvole.html index fa8addc..13d8ca9 100644 --- a/web/templates/stranice/admin_dozvole.html +++ b/web/templates/stranice/admin_dozvole.html @@ -40,14 +40,17 @@ - - Magacin + + Dashboard - Pregled artikala - - {{if eq .KorisnikUloga "superadmin"}}{{end}} + Prikaz prihoda + + {{if eq .KorisnikUloga "superadmin"}}{{end}} + + + Magacin Dodavanje artikala @@ -86,12 +89,6 @@ {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - Izmena kategorija - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - Brisanje kategorija @@ -194,6 +191,12 @@ {{if eq .KorisnikUloga "superadmin"}}{{end}} + + + Storniranje prodaje + + {{if eq .KorisnikUloga "superadmin"}}{{end}} + @@ -223,33 +226,6 @@ - - Podsetnici - - Pregled podsetnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - - Dodavanje podsetnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - - Izmena podsetnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - - Brisanje podsetnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - Izveštaji @@ -273,37 +249,19 @@ {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - Korisnici - Pregled korisnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} + Izmena pozadine prijavne stranice + + {{if eq .KorisnikUloga "superadmin"}}{{end}} - - Dodavanje korisnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - + + Tema - Izmena korisnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - - Brisanje korisnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - - Promena uloge korisnika - - {{if eq .KorisnikUloga "superadmin"}}{{end}} + Lična tema naloga + + {{if eq .KorisnikUloga "superadmin"}}{{end}} diff --git a/web/templates/stranice/magacin.html b/web/templates/stranice/magacin.html index 4631461..7538d98 100644 --- a/web/templates/stranice/magacin.html +++ b/web/templates/stranice/magacin.html @@ -19,6 +19,34 @@ .magacin-kartica:nth-child(3) { animation-delay: 0.16s; } .magacin-kartica:nth-child(4) { animation-delay: 0.22s; } .magacin-kartica:nth-child(5) { animation-delay: 0.28s; } + + /* padajući meni za premeštanje artikla u drugu kategoriju */ + .premesti-meni summary { list-style: none; } + .premesti-meni summary::-webkit-details-marker { display: none; } + .premesti-meni .premesti-panel { + margin-top: 6px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--pozadina); + border: 0.5px solid var(--ivica); + border-radius: 8px; + padding: 4px; + min-width: 150px; + } + .premesti-opcija { + text-align: left; + padding: 7px 10px; + background: none; + border: none; + border-radius: 6px; + color: var(--tekst-glavni); + font-size: 13px; + cursor: pointer; + white-space: nowrap; + } + .premesti-opcija:hover { background: var(--pozadina-hover); } + .premesti-opcija-prazno { color: var(--tekst-sporedni); border-top: 0.5px solid var(--ivica); } {{end}} @@ -31,6 +59,9 @@ {{if .Obrisan}}
Artikal je uspešno obrisan.
{{end}} + {{if .Premesten}} +
Artikal je premešten u drugu kategoriju.
+ {{end}}
@@ -101,6 +132,17 @@ Izmeni {{end}} + {{if index $.Dozvole "artikal.premesti"}}{{if $.Kategorije}} +
+ Premesti +
+ {{range $.Kategorije}} + + {{end}} + +
+
+ {{end}}{{end}} {{if index $.Dozvole "artikal.obrisi"}} @@ -133,10 +175,21 @@
{{.KategorijaNaziv}}
{{end}}
-
+
{{if index $.Dozvole "artikal.izmeni"}} Izmeni {{end}} + {{if index $.Dozvole "artikal.premesti"}}{{if $.Kategorije}} +
+ Premesti +
+ {{range $.Kategorije}} + + {{end}} + +
+
+ {{end}}{{end}} {{if index $.Dozvole "artikal.obrisi"}} diff --git a/web/templates/stranice/podesavanja_sistem.html b/web/templates/stranice/podesavanja_sistem.html index 37e092c..e0da157 100644 --- a/web/templates/stranice/podesavanja_sistem.html +++ b/web/templates/stranice/podesavanja_sistem.html @@ -35,6 +35,33 @@
+ +
+
Automatski backup
+
+ +
+
+ + +
+
+ + +
+ +
+
+ Backup se pravi pri pokretanju programa i zatim na svakih {{.BackupIntervalSati}} h. Najstarije kopije se brišu kad ih ima više od zadatog broja. +
+
+
+