Optimizacije — SQLite WAL, template keš, gzip kompresija, build skript

This commit is contained in:
2026-06-03 21:18:12 +02:00
parent 2401f6d5ec
commit 974d76360a
46 changed files with 1470 additions and 579 deletions
+1
View File
@@ -0,0 +1 @@
NTECH_PORT=3000
+1 -2
View File
@@ -8,8 +8,7 @@
*.db-wal
# Promenljive okruženja
.env
.env.*
ntech.env
# Privremeni fajlovi
*.tmp
Executable
+15
View File
@@ -0,0 +1,15 @@
Napravi build.sh skriptu u korenu projekta sa sledećim sadržajem:
#!/bin/bash
VERSION=${1:-"dev"}
echo "Buildovanje NTech v$VERSION..."
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -ldflags "-X main.Verzija=$VERSION -s -w" -o ntech ./cmd/ntech
if [ $? -eq 0 ]; then
echo "Build završen: ntech v$VERSION"
ls -lh ntech
else
echo "Build neuspešan"
exit 1
fi
+28 -1
View File
@@ -16,11 +16,15 @@ import (
ntechmw "ntech/internal/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
)
// Verzija se postavlja pri produkcijskom buildu: go build -ldflags "-X main.Verzija=1.2.0"
var Verzija = "dev"
func main() {
godotenv.Load()
godotenv.Load("ntech.env")
if config.JelPrvoPokretanje() {
config.PokreniSetup()
@@ -60,9 +64,22 @@ func main() {
}
}()
os.MkdirAll("web/static/uploads", 0755)
h := handler.Novi(db)
h.Verzija = Verzija
if os.Getenv("NTECH_ENV") == "production" {
kes, err := handler.KreirajKes()
if err != nil {
log.Fatalf("Greška pri kreiranju keša šablona: %v", err)
}
h.Templates = kes
log.Printf("Keš šablona kreiran: %d šablona", len(kes))
}
r := chi.NewRouter()
r.Use(middleware.Compress(5))
// statični fajlovi
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
@@ -86,6 +103,7 @@ func main() {
r.Get("/dashboard", h.Dashboard)
r.Get("/podesavanja", h.Podesavanja)
r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja)
r.Post("/podesavanja/logo", h.OtpremiLogo)
r.Get("/podesavanja/backup", h.BackupBaze)
r.Get("/tema/{tema}", h.PromeniTemu)
r.Get("/magacin", h.Magacin)
@@ -129,6 +147,15 @@ func main() {
r.Get("/prodaja/{id}/stampa", h.StampaProdaje)
r.Get("/prodaja/{id}", h.DetaljiProdaje)
// podsetnici
r.Get("/podsetnici", h.Podsetnici)
r.Get("/podsetnici/novi", h.NoviPodsetnik)
r.Post("/podsetnici/novi", h.SacuvajPodsetnik)
r.Get("/podsetnici/izmeni/{id}", h.IzmeniPodsetnik)
r.Post("/podsetnici/izmeni/{id}", h.SacuvajIzmenePodsetnika)
r.Post("/podsetnici/zavrseno/{id}", h.OznaciPodsetnik)
r.Post("/podsetnici/obrisi/{id}", h.ObrisiPodsetnik)
// admin rute
r.Get("/admin/korisnici", h.AdminKorisnici)
r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika)
+3 -3
View File
@@ -33,7 +33,7 @@ func NadjiSlobodanPort() int {
// proverava da li je ovo prvo pokretanje programa
func JelPrvoPokretanje() bool {
_, err := os.Stat(".env")
_, err := os.Stat("ntech.env")
return os.IsNotExist(err)
}
@@ -55,8 +55,8 @@ func StatusPortova() []PortStatus {
return rezultat
}
// SacuvajEnv upisuje izabrani port u .env fajl
// SacuvajEnv upisuje izabrani port u ntech.env fajl
func SacuvajEnv(port int) error {
sadrzaj := fmt.Sprintf("NTECH_PORT=%d\n", port)
return os.WriteFile(".env", []byte(sadrzaj), 0600)
return os.WriteFile("ntech.env", []byte(sadrzaj), 0600)
}
+16
View File
@@ -97,3 +97,19 @@ type SesijeRepository interface {
Obrisi(ctx context.Context, token string) error
ObrisiIstekle(ctx context.Context) error
}
// PodsetnikFilter definiše parametre za filtriranje liste podsetnika
type PodsetnikFilter struct {
SamoAktivni bool // true = samo nezavršeni; false = svi
}
// PodsetnikRepository definiše operacije nad podsetnicima
type PodsetnikRepository interface {
Lista(ctx context.Context, filter PodsetnikFilter) ([]model.Podsetnik, error)
DohvatiID(ctx context.Context, id int64) (*model.Podsetnik, error)
Kreiraj(ctx context.Context, p *model.Podsetnik) (int64, error)
Izmeni(ctx context.Context, p *model.Podsetnik) error
OznaciZavrsenim(ctx context.Context, id int64, zavrseno bool) error
Obrisi(ctx context.Context, id int64) error
BrojAktivnih(ctx context.Context) (int, error)
}
+11 -4
View File
@@ -10,16 +10,23 @@ import (
_ "modernc.org/sqlite"
)
// OtvoriDB otvara konekciju ka SQLite bazi i uključuje strane ključeve
// OtvoriDB otvara konekciju ka SQLite bazi i primenjuje performance PRAGMA podešavanja
func OtvoriDB(putanja string) (*sql.DB, error) {
db, err := sql.Open("sqlite", putanja)
if err != nil {
return nil, fmt.Errorf("ntech: OtvoriDB: %w", err)
}
// uključujemo podršku za strane ključeve — SQLite je ne uključuje automatski
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
return nil, fmt.Errorf("ntech: OtvoriDB: foreign_keys: %w", err)
pragme := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA cache_size=10000",
"PRAGMA foreign_keys=ON",
}
for _, p := range pragme {
if _, err := db.Exec(p); err != nil {
return nil, fmt.Errorf("ntech: OtvoriDB: %s: %w", p, err)
}
}
return db, nil
+152
View File
@@ -0,0 +1,152 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/db"
"ntech/internal/model"
)
// PodsetnikRepo je SQLite implementacija PodsetnikRepository interfejsa
type PodsetnikRepo struct {
db *sql.DB
}
// NoviPodsetnikRepo kreira novi PodsetnikRepo
func NoviPodsetnikRepo(db *sql.DB) *PodsetnikRepo {
return &PodsetnikRepo{db: db}
}
// Lista vraća listu podsetnika sa opcionim filterom
func (r *PodsetnikRepo) Lista(ctx context.Context, filter db.PodsetnikFilter) ([]model.Podsetnik, error) {
upit := `
SELECT id, naslov, napomena, datum_podsecanja, zavrseno, tip, datum_unosa
FROM podsetnici
WHERE 1=1`
if filter.SamoAktivni {
upit += " AND zavrseno = 0"
}
upit += " ORDER BY datum_podsecanja ASC"
redovi, err := r.db.QueryContext(ctx, upit)
if err != nil {
return nil, fmt.Errorf("ntech: PodsetnikRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.Podsetnik
for redovi.Next() {
var p model.Podsetnik
var napomena sql.NullString
var zavrseno int
err := redovi.Scan(
&p.ID, &p.Naslov, &napomena, &p.DatumPodsecanja, &zavrseno, &p.Tip, &p.DatumUnosa,
)
if err != nil {
return nil, fmt.Errorf("ntech: PodsetnikRepo.Lista: scan: %w", err)
}
p.Napomena = napomena.String
p.Zavrseno = zavrseno == 1
rezultat = append(rezultat, p)
}
return rezultat, nil
}
// DohvatiID vraća jedan podsetnik po ID-u
func (r *PodsetnikRepo) DohvatiID(ctx context.Context, id int64) (*model.Podsetnik, error) {
var p model.Podsetnik
var napomena sql.NullString
var zavrseno int
err := r.db.QueryRowContext(ctx, `
SELECT id, naslov, napomena, datum_podsecanja, zavrseno, tip, datum_unosa
FROM podsetnici WHERE id = ?`, id).Scan(
&p.ID, &p.Naslov, &napomena, &p.DatumPodsecanja, &zavrseno, &p.Tip, &p.DatumUnosa,
)
if err != nil {
return nil, fmt.Errorf("ntech: PodsetnikRepo.DohvatiID: %w", err)
}
p.Napomena = napomena.String
p.Zavrseno = zavrseno == 1
return &p, nil
}
// Kreiraj dodaje novi podsetnik u bazu
func (r *PodsetnikRepo) Kreiraj(ctx context.Context, p *model.Podsetnik) (int64, error) {
rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO podsetnici (naslov, napomena, datum_podsecanja, tip)
VALUES (?, ?, ?, ?)`,
p.Naslov, nullString(p.Napomena), p.DatumPodsecanja, p.Tip,
)
if err != nil {
return 0, fmt.Errorf("ntech: PodsetnikRepo.Kreiraj: %w", err)
}
id, err := rezultat.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: PodsetnikRepo.Kreiraj: last insert id: %w", err)
}
return id, nil
}
// Izmeni ažurira postojeći podsetnik
func (r *PodsetnikRepo) Izmeni(ctx context.Context, p *model.Podsetnik) error {
_, err := r.db.ExecContext(ctx, `
UPDATE podsetnici SET
naslov = ?, napomena = ?, datum_podsecanja = ?, tip = ?
WHERE id = ?`,
p.Naslov, nullString(p.Napomena), p.DatumPodsecanja, p.Tip, p.ID,
)
if err != nil {
return fmt.Errorf("ntech: PodsetnikRepo.Izmeni: %w", err)
}
return nil
}
// OznaciZavrsenim postavlja ili uklanja oznaku završenosti
func (r *PodsetnikRepo) OznaciZavrsenim(ctx context.Context, id int64, zavrseno bool) error {
val := 0
if zavrseno {
val = 1
}
_, err := r.db.ExecContext(ctx,
"UPDATE podsetnici SET zavrseno = ? WHERE id = ?", val, id,
)
if err != nil {
return fmt.Errorf("ntech: PodsetnikRepo.OznaciZavrsenim: %w", err)
}
return nil
}
// Obrisi briše podsetnik po ID-u
func (r *PodsetnikRepo) Obrisi(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM podsetnici WHERE id = ?", id)
if err != nil {
return fmt.Errorf("ntech: PodsetnikRepo.Obrisi: %w", err)
}
return nil
}
// BrojAktivnih vraća broj nezavršenih podsetnika
func (r *PodsetnikRepo) BrojAktivnih(ctx context.Context) (int, error) {
var broj int
err := r.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM podsetnici WHERE zavrseno = 0",
).Scan(&broj)
if err != nil {
return 0, fmt.Errorf("ntech: PodsetnikRepo.BrojAktivnih: %w", err)
}
return broj, nil
}
+4 -20
View File
@@ -56,7 +56,7 @@ func (h *Handler) AdminKorisnici(w http.ResponseWriter, r *http.Request) {
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
renderujAdminTemplate(w, "web/templates/stranice/admin_korisnici.html", podaci)
h.renderujTemplate(w, "admin_korisnici", podaci)
}
// AdminSacuvajKorisnika kreira novog korisnika
@@ -196,7 +196,7 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
TotpAktivan: svezi.TotpTajna != "",
}
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
h.renderujTemplate(w, "admin_profil", podaci)
}
// AdminPromeniLozinku menja lozinku prijavljenog korisnika
@@ -265,7 +265,7 @@ func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) {
TotpQR: template.URL("data:image/png;base64," + totp.QRBase64),
}
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
h.renderujTemplate(w, "admin_profil", podaci)
}
// AdminTotpAktivacija verifikuje TOTP kod i čuva tajnu
@@ -305,7 +305,7 @@ func (h *Handler) AdminTotpAktivacija(w http.ResponseWriter, r *http.Request) {
TotpQR: template.URL("data:image/png;base64," + qr),
Greska: "totp",
}
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
h.renderujTemplate(w, "admin_profil", podaci)
return
}
@@ -333,22 +333,6 @@ func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, "/admin/profil?sacuvano=totp_off", http.StatusSeeOther)
}
func renderujAdminTemplate(w http.ResponseWriter, stranica string, podaci any) {
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
stranica,
)
if err != nil {
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
}
// parseBoolForm čita boolean vrednost iz forme
func parseBoolForm(s string) bool {
b, _ := strconv.ParseBool(s)
+16 -25
View File
@@ -1,7 +1,6 @@
package handler
import (
"html/template"
"log"
"net/http"
"time"
@@ -20,7 +19,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
return
}
var brojArtikala, aktivniServisi, kriticnaZaliha int
var brojArtikala, aktivniServisi, kriticnaZaliha, aktivniPodsetnici int
var prihodOvogMeseca float64
if err := h.DB.QueryRowContext(ctx,
@@ -49,6 +48,12 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
log.Printf("dashboard: kriticna zaliha: %v", err)
}
if n, err := h.PodsetniciFRepo.BrojAktivnih(ctx); err != nil {
log.Printf("dashboard: aktivni podsetnici: %v", err)
} else {
aktivniPodsetnici = n
}
// poslednjih 5 servisnih naloga sa datumom prijema
servisRedovi, err := h.DB.QueryContext(ctx, `
SELECT uredjaj, status, datum_prijema FROM servisni_nalozi
@@ -132,31 +137,17 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin",
},
BrojArtikala: brojArtikala,
AktivniServisi: aktivniServisi,
PrihodOvogMeseca: prihodOvogMeseca,
KriticnaZaliha: kriticnaZaliha,
PoslednjiServisi: poslednjiServisi,
KriticneZalihe: kriticneZalihe,
PoslednjeProdaje: poslednjeProdaje,
BrojArtikala: brojArtikala,
AktivniServisi: aktivniServisi,
PrihodOvogMeseca: prihodOvogMeseca,
KriticnaZaliha: kriticnaZaliha,
AktivniPodsetnici: aktivniPodsetnici,
PoslednjiServisi: poslednjiServisi,
KriticneZalihe: kriticneZalihe,
PoslednjeProdaje: poslednjeProdaje,
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/dashboard.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "dashboard", podaci)
}
// bojaTackeServisa vraća hex boju tačke prema statusu naloga
+7 -39
View File
@@ -1,8 +1,6 @@
package handler
import (
"html/template"
"log"
"net/http"
"strings"
@@ -62,22 +60,7 @@ func (h *Handler) Dobavljaci(w http.ResponseWriter, r *http.Request) {
Obrisan: r.URL.Query().Get("obrisan") == "1",
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/dobavljaci.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "dobavljaci", podaci)
}
// NoviDobavljac prikazuje praznu formu za unos novog dobavljača
@@ -88,7 +71,7 @@ func (h *Handler) NoviDobavljac(w http.ResponseWriter, r *http.Request) {
return
}
renderujFormuDobavljaca(w, PodaciFormeDobavljaca{
h.renderujFormuDobavljaca(w, PodaciFormeDobavljaca{
PodaciStranice: model.PodaciStranice{
Stranica: "dobavljaci",
NaslovStranice: "Novi dobavljač",
@@ -113,7 +96,7 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) {
dobavljac, greska := parseFormuDobavljaca(r)
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
renderujFormuDobavljaca(w, PodaciFormeDobavljaca{
h.renderujFormuDobavljaca(w, PodaciFormeDobavljaca{
PodaciStranice: model.PodaciStranice{
Stranica: "dobavljaci",
NaslovStranice: "Novi dobavljač",
@@ -159,7 +142,7 @@ func (h *Handler) IzmeniDobavljaca(w http.ResponseWriter, r *http.Request) {
return
}
renderujFormuDobavljaca(w, PodaciFormeDobavljaca{
h.renderujFormuDobavljaca(w, PodaciFormeDobavljaca{
PodaciStranice: model.PodaciStranice{
Stranica: "dobavljaci",
NaslovStranice: "Izmeni dobavljača",
@@ -192,7 +175,7 @@ func (h *Handler) SacuvajIzmeneDobavljaca(w http.ResponseWriter, r *http.Request
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
dobavljac.ID = id
renderujFormuDobavljaca(w, PodaciFormeDobavljaca{
h.renderujFormuDobavljaca(w, PodaciFormeDobavljaca{
PodaciStranice: model.PodaciStranice{
Stranica: "dobavljaci",
NaslovStranice: "Izmeni dobavljača",
@@ -257,21 +240,6 @@ func parseFormuDobavljaca(r *http.Request) (model.Dobavljac, string) {
}
// renderujFormuDobavljaca renderuje HTML šablon forme za unos ili izmenu dobavljača
func renderujFormuDobavljaca(w http.ResponseWriter, podaci PodaciFormeDobavljaca) {
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/dobavljac_forma.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
func (h *Handler) renderujFormuDobavljaca(w http.ResponseWriter, podaci PodaciFormeDobavljaca) {
h.renderujTemplate(w, "dobavljac_forma", podaci)
}
+9 -4
View File
@@ -2,6 +2,7 @@ package handler
import (
"database/sql"
"html/template"
"ntech/internal/db"
"ntech/internal/db/sqlite"
@@ -20,8 +21,11 @@ type Handler struct {
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetniciFRepo db.PodsetnikRepository
Verzija string
Templates map[string]*template.Template
}
// Novi kreira novi Handler sa datom bazom
@@ -35,8 +39,9 @@ func Novi(baza *sql.DB) *Handler {
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetniciFRepo: sqlite.NoviPodsetnikRepo(baza),
}
}
+1 -15
View File
@@ -257,19 +257,5 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
TopKlijenti: topKlijenti,
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/izvestaji.html",
)
if err != nil {
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
return
}
h.renderujTemplate(w, "izvestaji", podaci)
}
+1 -18
View File
@@ -1,8 +1,6 @@
package handler
import (
"html/template"
"log"
"net/http"
"strconv"
@@ -50,22 +48,7 @@ func (h *Handler) Kategorije(w http.ResponseWriter, r *http.Request) {
Obrisana: r.URL.Query().Get("obrisana") == "1",
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/kategorije.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "kategorije", podaci)
}
// DodajKategoriju prima POST i čuva novu kategoriju
+119
View File
@@ -0,0 +1,119 @@
package handler
import (
"fmt"
"html/template"
"log"
"net/http"
)
var bazniSabloni = []string{
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
}
// saSidebar su šabloni koji koriste base layout (sidebar + topbar)
var saSidebar = []string{
"admin_korisnici", "admin_profil",
"dashboard",
"dobavljaci", "dobavljac_forma",
"izvestaji",
"kategorije",
"klijenti", "klijent_forma",
"magacin", "magacin_forma",
"nabavke", "nabavka_forma", "nabavka_detalji",
"podesavanja",
"podsetnici", "podsetnik_forma",
"prodaja", "prodaja_detalji", "prodaja_forma",
"servis", "servis_forma", "servis_detalji",
}
// standalone su šabloni bez base layouta
var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa",
}
// KreirajKes parsuje sve šablone i vraća ih keširane u mapi
func KreirajKes() (map[string]*template.Template, error) {
kes := make(map[string]*template.Template)
for _, ime := range saSidebar {
fajlovi := make([]string, len(bazniSabloni), len(bazniSabloni)+1)
copy(fajlovi, bazniSabloni)
fajlovi = append(fajlovi, "web/templates/stranice/"+ime+".html")
t, err := template.ParseFiles(fajlovi...)
if err != nil {
return nil, fmt.Errorf("kes: %s: %w", ime, err)
}
kes[ime] = t
}
for _, ime := range standaloneIme {
t, err := template.ParseFiles("web/templates/stranice/" + ime + ".html")
if err != nil {
return nil, fmt.Errorf("kes: %s: %w", ime, err)
}
kes[ime] = t
}
return kes, nil
}
// renderujTemplate renderuje šablon sa base layoutom
// U produkciji koristi keš; u razvoju parsuje svaki put (hot reload)
func (h *Handler) renderujTemplate(w http.ResponseWriter, ime string, podaci any) {
var tmpl *template.Template
if h.Templates != nil {
t, ok := h.Templates[ime]
if !ok {
log.Printf("kes: šablon '%s' nije pronađen", ime)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
tmpl = t
} else {
fajlovi := make([]string, len(bazniSabloni), len(bazniSabloni)+1)
copy(fajlovi, bazniSabloni)
fajlovi = append(fajlovi, "web/templates/stranice/"+ime+".html")
var err error
if tmpl, err = template.ParseFiles(fajlovi...); err != nil {
log.Printf("greška pri parsiranju šablona %s: %v", ime, err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju šablona %s: %v", ime, err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
}
// renderujStandalone renderuje šablon bez base layouta (prijava, setup, itd.)
func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci any) {
var tmpl *template.Template
if h.Templates != nil {
t, ok := h.Templates[ime]
if !ok {
log.Printf("kes: standalone šablon '%s' nije pronađen", ime)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
tmpl = t
} else {
var err error
if tmpl, err = template.ParseFiles("web/templates/stranice/" + ime + ".html"); err != nil {
log.Printf("greška pri parsiranju šablona %s: %v", ime, err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
}
if err := tmpl.Execute(w, podaci); err != nil {
log.Printf("greška pri renderovanju šablona %s: %v", ime, err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
}
+9 -40
View File
@@ -1,7 +1,6 @@
package handler
import (
"html/template"
"log"
"net/http"
"strings"
@@ -62,22 +61,7 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) {
Obrisan: r.URL.Query().Get("obrisan") == "1",
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/klijenti.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "klijenti", podaci)
}
// NoviKlijent prikazuje praznu formu za unos novog klijenta
@@ -88,7 +72,7 @@ func (h *Handler) NoviKlijent(w http.ResponseWriter, r *http.Request) {
return
}
renderujFormuKlijenta(w, PodaciFormeKlijenta{
h.renderujFormuKlijenta(w, PodaciFormeKlijenta{
PodaciStranice: model.PodaciStranice{
Stranica: "klijenti",
NaslovStranice: "Novi klijent",
@@ -113,7 +97,7 @@ func (h *Handler) SacuvajKlijenta(w http.ResponseWriter, r *http.Request) {
klijent, greska := parseFormuKlijenta(r)
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
renderujFormuKlijenta(w, PodaciFormeKlijenta{
h.renderujFormuKlijenta(w, PodaciFormeKlijenta{
PodaciStranice: model.PodaciStranice{
Stranica: "klijenti",
NaslovStranice: "Novi klijent",
@@ -134,7 +118,7 @@ func (h *Handler) SacuvajKlijenta(w http.ResponseWriter, r *http.Request) {
if _, err := h.KlijentiRepo.Kreiraj(r.Context(), &klijent); err != nil {
log.Printf("greška pri čuvanju klijenta: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
renderujFormuKlijenta(w, PodaciFormeKlijenta{
h.renderujFormuKlijenta(w, PodaciFormeKlijenta{
PodaciStranice: model.PodaciStranice{
Stranica: "klijenti",
NaslovStranice: "Novi klijent",
@@ -175,7 +159,7 @@ func (h *Handler) IzmeniKlijenta(w http.ResponseWriter, r *http.Request) {
return
}
renderujFormuKlijenta(w, PodaciFormeKlijenta{
h.renderujFormuKlijenta(w, PodaciFormeKlijenta{
PodaciStranice: model.PodaciStranice{
Stranica: "klijenti",
NaslovStranice: "Izmeni klijenta",
@@ -208,7 +192,7 @@ func (h *Handler) SacuvajIzmenuKlijenta(w http.ResponseWriter, r *http.Request)
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijent.ID = id
renderujFormuKlijenta(w, PodaciFormeKlijenta{
h.renderujFormuKlijenta(w, PodaciFormeKlijenta{
PodaciStranice: model.PodaciStranice{
Stranica: "klijenti",
NaslovStranice: "Izmeni klijenta",
@@ -230,7 +214,7 @@ func (h *Handler) SacuvajIzmenuKlijenta(w http.ResponseWriter, r *http.Request)
if err := h.KlijentiRepo.Izmeni(r.Context(), &klijent); err != nil {
log.Printf("greška pri čuvanju izmene klijenta: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
renderujFormuKlijenta(w, PodaciFormeKlijenta{
h.renderujFormuKlijenta(w, PodaciFormeKlijenta{
PodaciStranice: model.PodaciStranice{
Stranica: "klijenti",
NaslovStranice: "Izmeni klijenta",
@@ -293,21 +277,6 @@ func parseFormuKlijenta(r *http.Request) (model.Klijent, string) {
}
// renderujFormuKlijenta renderuje HTML šablon forme za unos ili izmenu klijenta
func renderujFormuKlijenta(w http.ResponseWriter, podaci PodaciFormeKlijenta) {
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/klijent_forma.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
func (h *Handler) renderujFormuKlijenta(w http.ResponseWriter, podaci PodaciFormeKlijenta) {
h.renderujTemplate(w, "klijent_forma", podaci)
}
+1 -19
View File
@@ -1,8 +1,6 @@
package handler
import (
"html/template"
"log"
"net/http"
"strconv"
@@ -77,23 +75,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
Obrisan: r.URL.Query().Get("obrisan") == "1",
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/magacin.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
return
}
h.renderujTemplate(w, "magacin", podaci)
}
// ObrisiArtikal briše artikal po ID-u
+6 -23
View File
@@ -2,8 +2,6 @@ package handler
import (
"fmt"
"html/template"
"log"
"net/http"
"strconv"
@@ -52,7 +50,7 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
Izmena: false,
}
renderujFormuArtikla(w, podaci)
h.renderujFormuArtikla(w, podaci)
}
// SacuvajArtikal prima POST formu i čuva novi artikal
@@ -70,7 +68,7 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
if artikal.KategorijaID != nil {
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
}
renderujFormuArtikla(w, PodaciFormeArtikla{
h.renderujFormuArtikla(w, PodaciFormeArtikla{
PodaciStranice: model.PodaciStranice{
Stranica: "magacin",
NaslovStranice: "Novi artikal",
@@ -155,7 +153,7 @@ func (h *Handler) IzmeniArtikal(w http.ResponseWriter, r *http.Request) {
Izmena: true,
}
renderujFormuArtikla(w, podaci)
h.renderujFormuArtikla(w, podaci)
}
// SacuvajIzmenuArtikla prima POST formu i čuva izmenu artikla
@@ -181,7 +179,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
if artikal.KategorijaID != nil {
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
}
renderujFormuArtikla(w, PodaciFormeArtikla{
h.renderujFormuArtikla(w, PodaciFormeArtikla{
PodaciStranice: model.PodaciStranice{
Stranica: "magacin",
NaslovStranice: "Izmeni artikal",
@@ -258,21 +256,6 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
}
// renderujFormuArtikla renderuje HTML formu za artikal
func renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/magacin_forma.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
h.renderujTemplate(w, "magacin_forma", podaci)
}
+6 -52
View File
@@ -3,7 +3,6 @@ package handler
import (
"encoding/json"
"html/template"
"log"
"net/http"
"strconv"
"strings"
@@ -85,22 +84,7 @@ func (h *Handler) Nabavke(w http.ResponseWriter, r *http.Request) {
Obrisan: r.URL.Query().Get("obrisan") == "1",
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/nabavke.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "nabavke", podaci)
}
// NovaNabavka prikazuje formu za unos nove nabavke
@@ -129,7 +113,7 @@ func (h *Handler) NovaNabavka(w http.ResponseWriter, r *http.Request) {
return
}
renderujFormuNabavke(w, PodaciFormeNabavke{
h.renderujFormuNabavke(w, PodaciFormeNabavke{
PodaciStranice: model.PodaciStranice{
Stranica: "nabavke",
NaslovStranice: "Nova nabavka",
@@ -160,7 +144,7 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
artikli, _ := h.Artikli.Lista(r.Context(), db.ArtikalFilter{})
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
renderujFormuNabavke(w, PodaciFormeNabavke{
h.renderujFormuNabavke(w, PodaciFormeNabavke{
PodaciStranice: model.PodaciStranice{
Stranica: "nabavke",
NaslovStranice: "Nova nabavka",
@@ -240,22 +224,7 @@ func (h *Handler) DetaljiNabavke(w http.ResponseWriter, r *http.Request) {
DobavljacNaziv: dobavljacNaziv,
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/nabavka_detalji.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "nabavka_detalji", podaci)
}
// ObrisiNabavku prima POST zahtev i briše nabavku po ID-u
@@ -328,21 +297,6 @@ func parseFormuNabavke(r *http.Request) (model.Nabavka, []model.StavkaNabavke, s
}
// renderujFormuNabavke renderuje HTML šablon forme za unos nove nabavke
func renderujFormuNabavke(w http.ResponseWriter, podaci PodaciFormeNabavke) {
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/nabavka_forma.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
func (h *Handler) renderujFormuNabavke(w http.ResponseWriter, podaci PodaciFormeNabavke) {
h.renderujTemplate(w, "nabavka_forma", podaci)
}
+85 -17
View File
@@ -2,9 +2,12 @@ package handler
import (
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"ntech/internal/db/sqlite"
@@ -25,6 +28,8 @@ type PodaciPodesavanja struct {
LogoPutanja string
Tema string
Sacuvano bool
Verzija string
LogoGreska string
}
// Podesavanja renderuje stranicu podešavanja
@@ -55,23 +60,11 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
LogoPutanja: podesavanja["logo_putanja"],
Tema: podesavanja["tema"],
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Verzija: h.Verzija,
LogoGreska: r.URL.Query().Get("logo_greska"),
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/podesavanja.html",
)
if err != nil {
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
return
}
h.renderujTemplate(w, "podesavanja", podaci)
}
// SacuvajPodesavanja prima POST i čuva podešavanja u bazu
@@ -120,13 +113,88 @@ func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, privremeni)
}
// OtpremiLogo prima multipart upload slike loga i čuva je u web/static/uploads/
func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(2 << 20); err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
return
}
fajl, zaglavlje, err := r.FormFile("logo")
if err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Nije+odabran+fajl", http.StatusSeeOther)
return
}
defer fajl.Close()
// proveravamo ekstenziju
ext := strings.ToLower(filepath.Ext(zaglavlje.Filename))
dozvoljenoExt := map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
}
ocekivaniMime, ok := dozvoljenoExt[ext]
if !ok {
http.Redirect(w, r, "/podesavanja?logo_greska=Dozvoljeni+formati+su+PNG%2C+JPG+i+SVG", http.StatusSeeOther)
return
}
// za binarne formate proveravamo i stvarni tip fajla (SVG je tekstualni, preskačemo)
if ext != ".svg" {
buf := make([]byte, 512)
n, _ := fajl.Read(buf)
stvarniMime := http.DetectContentType(buf[:n])
if !strings.HasPrefix(stvarniMime, ocekivaniMime) {
http.Redirect(w, r, "/podesavanja?logo_greska=Sadržaj+fajla+ne+odgovara+odabranoj+ekstenziji", http.StatusSeeOther)
return
}
// vraćamo kursor na početak
if _, err := fajl.Seek(0, io.SeekStart); err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+obradi+fajla", http.StatusSeeOther)
return
}
}
// brišemo stare logo fajlove
stari, _ := filepath.Glob("web/static/uploads/logo.*")
for _, s := range stari {
os.Remove(s)
}
odrediste := "web/static/uploads/logo" + ext
dst, err := os.Create(odrediste)
if err != nil {
log.Printf("upload loga: ne mogu kreirati fajl: %v", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
return
}
defer dst.Close()
if _, err := io.Copy(dst, fajl); err != nil {
log.Printf("upload loga: greška pri kopiranju: %v", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
return
}
putanja := "/static/uploads/logo" + ext
if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
log.Printf("upload loga: greška pri čuvanju putanje: %v", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther)
}
// PromeniTemu menja aktivnu temu i vraća na prethodnu stranicu
func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
tema := chi.URLParam(r, "tema")
// proveravamo da li je validna tema
validne := map[string]bool{
"tamna": true, "svetla": true, "zelena": true, "ljubicasta": true,
"tamna": true, "svetla": true,
}
if !validne[tema] {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
+267
View File
@@ -0,0 +1,267 @@
package handler
import (
"log"
"net/http"
"strings"
"time"
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// podaciPodsetnici su podaci za stranicu sa listom podsetnika
type podaciPodsetnici struct {
model.PodaciStranice
Podsetnici []model.Podsetnik
SamoAktivni bool
Sacuvano bool
Obrisan bool
}
// podaciPodsetnikForma su podaci za formu novog/izmenjenog podsetnika
type podaciPodsetnikForma struct {
model.PodaciStranice
Podsetnik model.Podsetnik
Greska string
Izmena bool
}
// Podsetnici renderuje listu podsetnika
func (h *Handler) Podsetnici(w http.ResponseWriter, r *http.Request) {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
samoAktivni := r.URL.Query().Get("samo_aktivni") == "1"
lista, err := h.PodsetniciFRepo.Lista(r.Context(), db.PodsetnikFilter{SamoAktivni: samoAktivni})
if err != nil {
http.Error(w, "Greška pri učitavanju podsetnika", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "podsetnici"
ps.NaslovStranice = "Podsetnici"
podaci := podaciPodsetnici{
PodaciStranice: ps,
Podsetnici: lista,
SamoAktivni: samoAktivni,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1",
}
h.renderujTemplate(w, "podsetnici", podaci)
}
// NoviPodsetnik prikazuje praznu formu za unos novog podsetnika
func (h *Handler) NoviPodsetnik(w http.ResponseWriter, r *http.Request) {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "podsetnici"
ps.NaslovStranice = "Novi podsetnik"
h.renderujFormuPodsetnika(w, podaciPodsetnikForma{
PodaciStranice: ps,
Izmena: false,
})
}
// SacuvajPodsetnik prima POST formu i upisuje novi podsetnik u bazu
func (h *Handler) SacuvajPodsetnik(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
podsetnik, greska := parseFormuPodsetnika(r)
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "podsetnici"
ps.NaslovStranice = "Novi podsetnik"
h.renderujFormuPodsetnika(w, podaciPodsetnikForma{
PodaciStranice: ps,
Podsetnik: podsetnik,
Greska: greska,
Izmena: false,
})
return
}
if _, err := h.PodsetniciFRepo.Kreiraj(r.Context(), &podsetnik); err != nil {
log.Printf("greška pri čuvanju podsetnika: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "podsetnici"
ps.NaslovStranice = "Novi podsetnik"
h.renderujFormuPodsetnika(w, podaciPodsetnikForma{
PodaciStranice: ps,
Podsetnik: podsetnik,
Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.",
Izmena: false,
})
return
}
http.Redirect(w, r, "/podsetnici?sacuvano=1", http.StatusSeeOther)
}
// IzmeniPodsetnik učitava podsetnik po ID-u i prikazuje popunjenu formu za izmenu
func (h *Handler) IzmeniPodsetnik(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID podsetnika", http.StatusBadRequest)
return
}
podsetnik, err := h.PodsetniciFRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "Podsetnik nije pronađen", http.StatusNotFound)
return
}
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "podsetnici"
ps.NaslovStranice = "Izmeni podsetnik"
h.renderujFormuPodsetnika(w, podaciPodsetnikForma{
PodaciStranice: ps,
Podsetnik: *podsetnik,
Izmena: true,
})
}
// SacuvajIzmenePodsetnika prima POST formu i ažurira postojeći podsetnik u bazi
func (h *Handler) SacuvajIzmenePodsetnika(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID podsetnika", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
podsetnik, greska := parseFormuPodsetnika(r)
podsetnik.ID = id
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "podsetnici"
ps.NaslovStranice = "Izmeni podsetnik"
h.renderujFormuPodsetnika(w, podaciPodsetnikForma{
PodaciStranice: ps,
Podsetnik: podsetnik,
Greska: greska,
Izmena: true,
})
return
}
if err := h.PodsetniciFRepo.Izmeni(r.Context(), &podsetnik); err != nil {
log.Printf("greška pri čuvanju izmene podsetnika: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "podsetnici"
ps.NaslovStranice = "Izmeni podsetnik"
h.renderujFormuPodsetnika(w, podaciPodsetnikForma{
PodaciStranice: ps,
Podsetnik: podsetnik,
Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.",
Izmena: true,
})
return
}
http.Redirect(w, r, "/podsetnici?sacuvano=1", http.StatusSeeOther)
}
// OznaciPodsetnik prima POST zahtev i menja status završenosti podsetnika
func (h *Handler) OznaciPodsetnik(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID podsetnika", http.StatusBadRequest)
return
}
// učitamo trenutni status pa ga preokrenemo
podsetnik, err := h.PodsetniciFRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "Podsetnik nije pronađen", http.StatusNotFound)
return
}
if err := h.PodsetniciFRepo.OznaciZavrsenim(r.Context(), id, !podsetnik.Zavrseno); err != nil {
http.Error(w, "Greška pri ažuriranju statusa", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/podsetnici", http.StatusSeeOther)
}
// ObrisiPodsetnik prima POST zahtev i briše podsetnik po ID-u
func (h *Handler) ObrisiPodsetnik(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID podsetnika", http.StatusBadRequest)
return
}
if err := h.PodsetniciFRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju podsetnika", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/podsetnici?obrisan=1", http.StatusSeeOther)
}
// parseFormuPodsetnika čita polja iz HTTP forme, validira ih i vraća model i eventualnu grešku
func parseFormuPodsetnika(r *http.Request) (model.Podsetnik, string) {
naslov := strings.TrimSpace(r.FormValue("naslov"))
if naslov == "" {
return model.Podsetnik{}, "Naslov je obavezan."
}
datumStr := strings.TrimSpace(r.FormValue("datum_podsecanja"))
if datumStr == "" {
return model.Podsetnik{Naslov: naslov}, "Datum podsećanja je obavezan."
}
datum, err := time.Parse("2006-01-02", datumStr)
if err != nil {
return model.Podsetnik{Naslov: naslov}, "Datum podsećanja nije u ispravnom formatu."
}
return model.Podsetnik{
Naslov: naslov,
Napomena: strings.TrimSpace(r.FormValue("napomena")),
DatumPodsecanja: datum,
Tip: model.TipOpsti,
}, ""
}
// renderujFormuPodsetnika renderuje HTML šablon forme za unos ili izmenu podsetnika
func (h *Handler) renderujFormuPodsetnika(w http.ResponseWriter, podaci podaciPodsetnikForma) {
h.renderujTemplate(w, "podsetnik_forma", podaci)
}
+3 -14
View File
@@ -1,7 +1,6 @@
package handler
import (
"html/template"
"net/http"
"time"
@@ -22,7 +21,7 @@ func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
}
greska := r.URL.Query().Get("greska")
renderujStandaloneTemplate(w, "web/templates/stranice/prijava.html", map[string]any{
h.renderujStandalone(w, "prijava", map[string]any{
"Greska": greska,
})
}
@@ -84,7 +83,7 @@ func (h *Handler) PrikazTotp(w http.ResponseWriter, r *http.Request) {
}
greska := r.URL.Query().Get("greska")
renderujStandaloneTemplate(w, "web/templates/stranice/totp_provera.html", map[string]any{
h.renderujStandalone(w, "totp_provera", map[string]any{
"Greska": greska,
})
}
@@ -133,7 +132,7 @@ func (h *Handler) PrikazSetupa(w http.ResponseWriter, r *http.Request) {
return
}
greska := r.URL.Query().Get("greska")
renderujStandaloneTemplate(w, "web/templates/stranice/setup.html", map[string]any{
h.renderujStandalone(w, "setup", map[string]any{
"Greska": greska,
})
}
@@ -200,13 +199,3 @@ func napraviKolacic(token string, istice time.Time) *http.Cookie {
}
}
func renderujStandaloneTemplate(w http.ResponseWriter, putanja string, podaci any) {
tmpl, err := template.ParseFiles(putanja)
if err != nil {
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.Execute(w, podaci); err != nil {
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
}
+7 -62
View File
@@ -105,22 +105,7 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) {
Pretraga: pretraga,
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/prodaja.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "prodaja", podaci)
}
// NovaProdaja prikazuje formu za unos novog prodajnog naloga
@@ -143,7 +128,7 @@ func (h *Handler) NovaProdaja(w http.ResponseWriter, r *http.Request) {
return
}
renderujFormuProdaje(w, PodaciFormeProdaje{
h.renderujFormuProdaje(w, PodaciFormeProdaje{
PodaciStranice: model.PodaciStranice{
Stranica: "prodaja",
NaslovStranice: "Nova prodaja",
@@ -173,7 +158,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
artikli, _ := h.Artikli.Lista(r.Context(), appdb.ArtikalFilter{})
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
renderujFormuProdaje(w, PodaciFormeProdaje{
h.renderujFormuProdaje(w, PodaciFormeProdaje{
PodaciStranice: model.PodaciStranice{
Stranica: "prodaja",
NaslovStranice: "Nova prodaja",
@@ -282,22 +267,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/prodaja_detalji.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "prodaja_detalji", podaci)
}
// StampaProdaje renderuje print-friendly stranicu za dati prodajni nalog
@@ -349,17 +319,7 @@ func (h *Handler) StampaProdaje(w http.ResponseWriter, r *http.Request) {
PIB: podesavanja["pib"],
}
tmpl, err := template.ParseFiles("web/templates/stranice/prodaja_stampa.html")
if err != nil {
log.Printf("greška pri učitavanju šablona za štampu: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "prodaja_stampa.html", podaci); err != nil {
log.Printf("greška pri renderovanju štampe: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujStandalone(w, "prodaja_stampa", podaci)
}
// ObrisiProdaju prima POST zahtev, vraća stanje na magacin i briše nalog
@@ -430,21 +390,6 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
}
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
func renderujFormuProdaje(w http.ResponseWriter, podaci PodaciFormeProdaje) {
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/prodaja_forma.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
func (h *Handler) renderujFormuProdaje(w http.ResponseWriter, podaci PodaciFormeProdaje) {
h.renderujTemplate(w, "prodaja_forma", podaci)
}
+10 -56
View File
@@ -1,7 +1,6 @@
package handler
import (
"html/template"
"log"
"net/http"
"strconv"
@@ -79,22 +78,7 @@ func (h *Handler) Servis(w http.ResponseWriter, r *http.Request) {
Obrisan: r.URL.Query().Get("obrisan") == "1",
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/servis.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "servis", podaci)
}
// NoviNalog generiše broj naloga i prikazuje praznu formu za unos
@@ -117,7 +101,7 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) {
return
}
renderujFormuNaloga(w, PodaciFormeNaloga{
h.renderujFormuNaloga(w, PodaciFormeNaloga{
PodaciStranice: model.PodaciStranice{
Stranica: "servis",
NaslovStranice: "Novi nalog",
@@ -146,7 +130,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
renderujFormuNaloga(w, PodaciFormeNaloga{
h.renderujFormuNaloga(w, PodaciFormeNaloga{
PodaciStranice: model.PodaciStranice{
Stranica: "servis",
NaslovStranice: "Novi nalog",
@@ -171,7 +155,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
log.Printf("greška pri čuvanju naloga: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
renderujFormuNaloga(w, PodaciFormeNaloga{
h.renderujFormuNaloga(w, PodaciFormeNaloga{
PodaciStranice: model.PodaciStranice{
Stranica: "servis",
NaslovStranice: "Novi nalog",
@@ -220,7 +204,7 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
return
}
renderujFormuNaloga(w, PodaciFormeNaloga{
h.renderujFormuNaloga(w, PodaciFormeNaloga{
PodaciStranice: model.PodaciStranice{
Stranica: "servis",
NaslovStranice: "Izmeni nalog",
@@ -256,7 +240,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
nalog.ID = id
renderujFormuNaloga(w, PodaciFormeNaloga{
h.renderujFormuNaloga(w, PodaciFormeNaloga{
PodaciStranice: model.PodaciStranice{
Stranica: "servis",
NaslovStranice: "Izmeni nalog",
@@ -281,7 +265,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
log.Printf("greška pri čuvanju izmene naloga: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
renderujFormuNaloga(w, PodaciFormeNaloga{
h.renderujFormuNaloga(w, PodaciFormeNaloga{
PodaciStranice: model.PodaciStranice{
Stranica: "servis",
NaslovStranice: "Izmeni nalog",
@@ -368,22 +352,7 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/servis_detalji.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
h.renderujTemplate(w, "servis_detalji", podaci)
}
// parseFormuNaloga čita i validira polja iz HTTP forme
@@ -448,21 +417,6 @@ func parseOpcionuCenu(s string) *float64 {
}
// renderujFormuNaloga renderuje HTML šablon forme za unos ili izmenu servisnog naloga
func renderujFormuNaloga(w http.ResponseWriter, podaci PodaciFormeNaloga) {
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/servis_forma.html",
)
if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
func (h *Handler) renderujFormuNaloga(w http.ResponseWriter, podaci PodaciFormeNaloga) {
h.renderujTemplate(w, "servis_forma", podaci)
}
+22
View File
@@ -0,0 +1,22 @@
package model
import "time"
// TipOpsti je jedini trenutno podržani tip podsetnika
const TipOpsti = "opsti"
// Podsetnik predstavlja jedan podsetnik
type Podsetnik struct {
ID int64
Naslov string
Napomena string
DatumPodsecanja time.Time
Zavrseno bool
Tip string
DatumUnosa time.Time
}
// JePrekoracen vraća true ako datum podsećanja je prošao a podsetnik nije završen
func (p Podsetnik) JePrekoracen() bool {
return !p.Zavrseno && p.DatumPodsecanja.Before(time.Now())
}
+8 -7
View File
@@ -40,11 +40,12 @@ type PodaciStranice struct {
// PodaciDashboarda su podaci specifični za dashboard stranicu
type PodaciDashboarda struct {
PodaciStranice
BrojArtikala int
AktivniServisi int
PrihodOvogMeseca float64
KriticnaZaliha int
PoslednjiServisi []StavkaServisa
KriticneZalihe []StavkaZalihe
PoslednjeProdaje []StavkaProdajePregled
BrojArtikala int
AktivniServisi int
PrihodOvogMeseca float64
KriticnaZaliha int
AktivniPodsetnici int
PoslednjiServisi []StavkaServisa
KriticneZalihe []StavkaZalihe
PoslednjeProdaje []StavkaProdajePregled
}
+29 -18
View File
@@ -21,7 +21,7 @@ body {
/* sidebar */
.sidebar {
width: 220px;
background: var(--sb-pozadina);
background: var(--sidebar-pozadina);
display: flex;
flex-direction: column;
transition: width 0.28s cubic-bezier(.4,0,.2,1);
@@ -40,7 +40,7 @@ body {
height: 72px;
padding: 0 12px;
gap: 10px;
border-bottom: 0.5px solid rgba(255,255,255,0.07);
border-bottom: 0.5px solid var(--ivica);
flex-shrink: 0;
}
@@ -49,7 +49,7 @@ body {
background: none;
border: none;
cursor: pointer;
color: var(--sb-tekst-aktivan);
color: var(--tekst-jak);
padding: 6px;
border-radius: 6px;
display: flex;
@@ -60,7 +60,7 @@ body {
}
.hamburger:hover {
background: var(--sb-hover);
background: var(--pozadina-hover);
}
/* logo zona */
@@ -81,14 +81,14 @@ body {
}
.logo-naziv {
color: var(--sb-tekst-aktivan);
color: var(--tekst-jak);
font-weight: 500;
font-size: 15px;
white-space: nowrap;
}
.logo-podnazlov {
color: var(--sb-tekst);
color: var(--tekst-sporedni);
font-size: 11px;
white-space: nowrap;
}
@@ -110,7 +110,7 @@ body {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--sb-tekst);
color: var(--tekst-sporedni);
padding: 12px 16px 4px;
white-space: nowrap;
opacity: 1;
@@ -127,7 +127,7 @@ body {
gap: 12px;
padding: 9px 16px;
cursor: pointer;
color: var(--sb-tekst);
color: var(--tekst-sporedni);
white-space: nowrap;
position: relative;
text-decoration: none;
@@ -135,13 +135,13 @@ body {
}
.nav-stavka:hover {
background: var(--sb-hover);
color: var(--sb-tekst-aktivan);
background: var(--pozadina-hover);
color: var(--tekst-jak);
}
.nav-stavka.aktivan {
background: var(--sb-aktivan);
color: var(--sb-tekst-aktivan);
color: var(--tekst-jak);
}
.nav-stavka.aktivan::before {
@@ -174,7 +174,7 @@ body {
.nav-separator {
height: 0.5px;
background: rgba(255,255,255,0.07);
background: var(--ivica);
margin: 8px 12px;
}
@@ -207,7 +207,7 @@ body {
/* dno sidebara */
.sidebar-dno {
padding: 8px 0;
border-top: 0.5px solid rgba(255,255,255,0.07);
border-top: 0.5px solid var(--ivica);
}
/* glavni sadržaj */
@@ -221,7 +221,7 @@ body {
/* topbar */
.topbar {
height: 56px;
background: var(--topbar);
background: var(--kartica);
border-bottom: 0.5px solid var(--ivica);
display: flex;
align-items: center;
@@ -250,12 +250,13 @@ body {
border: 0.5px solid var(--ivica);
border-radius: 12px;
padding: 16px;
box-shadow: var(--senka);
transition: transform 0.25s cubic-bezier(.4,0,.2,1), box-shadow 0.25s;
}
.kartica:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
box-shadow: var(--senka);
}
/* input polja — konzistentna za sve teme */
@@ -286,13 +287,23 @@ select:focus {
/* poruka o uspehu — konzistentna za sve teme */
.poruka-uspeh {
background: var(--kartica);
border: 0.5px solid var(--sb-akcent);
background: var(--poruka-uspeh-bg);
border: 0.5px solid var(--poruka-uspeh-boja);
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 14px;
color: var(--sb-akcent);
color: var(--poruka-uspeh-boja);
}
.poruka-greska {
background: rgba(207, 87, 87, 0.12);
border: 0.5px solid var(--greska);
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 14px;
color: var(--greska);
}
/* overlay za mobilni — tamni sloj iza sidebara */
-15
View File
@@ -1,15 +0,0 @@
:root {
--sb-pozadina: #4a3580;
--sb-hover: #5a4295;
--sb-aktivan: #6d52b0;
--sb-tekst: #c4b5e8;
--sb-tekst-aktivan: #ffffff;
--sb-akcent: #a855f7;
--pozadina: #f5f0ff;
--kartica: #ffffff;
--tekst-glavni: #1a1d2e;
--tekst-sporedni: #6b7280;
--ivica: #e5e7eb;
--topbar: #ffffff;
}
+19 -13
View File
@@ -1,15 +1,21 @@
:root {
--sb-pozadina: #f8fafc;
--sb-hover: #f1f5f9;
--sb-aktivan: #e8f0fe;
--sb-tekst: #64748b;
--sb-tekst-aktivan: #1a1d2e;
--sb-akcent: #4f7ef8;
--pozadina: #f0f2f8;
--kartica: #ffffff;
--tekst-glavni: #1a1d2e;
--tekst-sporedni: #6b7280;
--ivica: #e5e7eb;
--topbar: #ffffff;
--pozadina: #f6f8fa;
--kartica: #ffffff;
--kartica-2: #f0f2f5;
--pozadina-hover: #ebeef1;
--tekst-glavni: #24292f;
--tekst-sporedni: #57606a;
--tekst-jak: #1f2328;
--ivica: #d0d7de;
--sb-akcent: #1f6feb;
--sb-akcent-hover: #388bfd;
--sb-aktivan: #ebeef1;
--uspeh: #1a7f37;
--upozorenje: #9a6700;
--greska: #cf222e;
--info: #0969da;
--senka: 0 4px 12px rgba(0, 0, 0, 0.08);
--sidebar-pozadina: #ffffff;
--poruka-uspeh-bg: rgba(26, 127, 55, 0.1);
--poruka-uspeh-boja: #1a7f37;
}
+19 -13
View File
@@ -1,15 +1,21 @@
:root {
--sb-pozadina: #1a1d2e;
--sb-hover: #2a2d40;
--sb-aktivan: #3d4f7c;
--sb-tekst: #a0a8c0;
--sb-tekst-aktivan: #ffffff;
--sb-akcent: #4f7ef8;
--pozadina: #0f1117;
--kartica: #1a1d2e;
--tekst-glavni: #e2e8f0;
--tekst-sporedni: #8892a4;
--ivica: #2a2d40;
--topbar: #13151f;
--pozadina: #1f2228;
--kartica: #22262d;
--kartica-2: #2c313a;
--pozadina-hover: #2a2f37;
--tekst-glavni: #dbdee4;
--tekst-sporedni: #a0a8b5;
--tekst-jak: #f0f3f6;
--ivica: #353a44;
--sb-akcent: #4684d6;
--sb-akcent-hover: #5a96e6;
--sb-aktivan: #2a2f37;
--uspeh: #5db876;
--upozorenje: #e1b04a;
--greska: #cf5757;
--info: #5cb1d6;
--senka: 0 4px 12px rgba(0, 0, 0, 0.4);
--sidebar-pozadina: #1a1c20;
--poruka-uspeh-bg: rgba(93, 184, 118, 0.15);
--poruka-uspeh-boja: #5db876;
}
-15
View File
@@ -1,15 +0,0 @@
:root {
--sb-pozadina: #1a4d2e;
--sb-hover: #245c38;
--sb-aktivan: #2d7a47;
--sb-tekst: #a8d5b5;
--sb-tekst-aktivan: #ffffff;
--sb-akcent: #22c55e;
--pozadina: #f0faf4;
--kartica: #ffffff;
--tekst-glavni: #1a1d2e;
--tekst-sporedni: #6b7280;
--ivica: #e5e7eb;
--topbar: #ffffff;
}
+29
View File
@@ -0,0 +1,29 @@
<svg width="300" height="300" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradijent pozadine -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f2027"></stop>
<stop offset="50%" stop-color="#203a43"></stop>
<stop offset="100%" stop-color="#2c5364"></stop>
</linearGradient>
<!-- Senka -->
<filter id="textShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="6" stdDeviation="6" flood-color="#000" flood-opacity="0.5"></feDropShadow>
</filter>
</defs>
<!-- Pozadina -->
<rect width="300" height="300" rx="30" ry="30" fill="url(#bgGrad)"></rect>
<!-- Prsten -->
<ellipse cx="150" cy="160" rx="130" ry="36" fill="none" stroke="white" stroke-width="3" opacity="0.15" transform="rotate(-15 150 160)"></ellipse>
<!-- Unutrašnji prsten -->
<ellipse cx="150" cy="160" rx="110" ry="30" fill="none" stroke="white" stroke-width="1.5" opacity="0.1" transform="rotate(-15 150 160)"></ellipse>
<!-- VM tekst -->
<text x="50%" y="162" text-anchor="middle" dominant-baseline="middle" font-family="'Orbitron', sans-serif" font-size="100" fill="#ffffff" filter="url(#textShadow)">
VM
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+11 -11
View File
@@ -10,18 +10,12 @@
</button>
<div class="logo-zona">
{{if eq .LogoTip "slika"}}
<img src="/static/{{.LogoPutanja}}" alt="Logo" style="width:36px;height:36px;object-fit:contain;border-radius:6px;flex-shrink:0;">
<div>
<div class="logo-naziv">{{.NazivFirme}}</div>
<div class="logo-podnazlov">{{.Podnazlov}}</div>
</div>
{{else if eq .LogoTip "tekst"}}
<div>
<div class="logo-naziv">{{.NazivFirme}}</div>
<div class="logo-podnazlov">{{.Podnazlov}}</div>
</div>
<img src="{{.LogoPutanja}}" alt="Logo"
style="max-height:48px;width:auto;border-radius:8px;padding:4px;background:var(--kartica);box-shadow:var(--senka);transition:transform 0.2s;flex-shrink:0;"
onmouseover="this.style.transform='scale(1.02)'"
onmouseout="this.style.transform='scale(1)'">
{{else if eq .LogoTip "bez_naziva"}}
{{else}}
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.1);border:1.5px dashed rgba(255,255,255,0.25);flex-shrink:0;"></div>
<div>
<div class="logo-naziv">{{.NazivFirme}}</div>
<div class="logo-podnazlov">{{.Podnazlov}}</div>
@@ -69,6 +63,12 @@
<span class="nav-tooltip">Klijenti</span>
</a>
<a href="/podsetnici" class="nav-stavka {{if eq .Stranica "podsetnici"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span>Podsetnici</span>
<span class="nav-tooltip">Podsetnici</span>
</a>
<a href="/dobavljaci" class="nav-stavka {{if eq .Stranica "dobavljaci"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13" rx="1"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>
<span>Dobavljači</span>
+2 -4
View File
@@ -13,10 +13,8 @@
<span class="topbar-naslov">{{.NaslovStranice}}</span>
<div class="topbar-teme" style="display:flex;align-items:center;gap:8px;">
<a href="/tema/tamna" class="tema-krug {{if eq .Tema "tamna"}}tema-krug-aktivan{{end}}" style="background:#4f7ef8;" title="Tamna"></a>
<a href="/tema/svetla" class="tema-krug {{if eq .Tema "svetla"}}tema-krug-aktivan{{end}}" style="background:#e2e8f0;" title="Svetla"></a>
<a href="/tema/zelena" class="tema-krug {{if eq .Tema "zelena"}}tema-krug-aktivan{{end}}" style="background:#22c55e;" title="Zelena"></a>
<a href="/tema/ljubicasta" class="tema-krug {{if eq .Tema "ljubicasta"}}tema-krug-aktivan{{end}}" style="background:#a855f7;" title="Ljubičasta"></a>
<a href="/tema/tamna" class="tema-krug {{if eq .Tema "tamna"}}tema-krug-aktivan{{end}}" style="background:#1a1d27;" title="Tamna"></a>
<a href="/tema/svetla" class="tema-krug {{if eq .Tema "svetla"}}tema-krug-aktivan{{end}}" style="background:#f0f2f5;border:1px solid #e2e6ed;" title="Svetla"></a>
</div>
<div style="width:32px;height:32px;border-radius:50%;background:var(--sb-aktivan);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;color:#fff;flex-shrink:0;">
+39 -3
View File
@@ -2,11 +2,47 @@
{{define "naslov"}}Korisnici — NTech{{end}}
{{define "dodatni-css"}}
<style>
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.poruka-animacija { animation: slideDown 0.3s ease forwards; }
.korisnici-tabela tbody tr {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.korisnici-tabela tbody tr:nth-child(1) { animation-delay: 0.04s; }
.korisnici-tabela tbody tr:nth-child(2) { animation-delay: 0.08s; }
.korisnici-tabela tbody tr:nth-child(3) { animation-delay: 0.12s; }
.korisnici-tabela tbody tr:nth-child(4) { animation-delay: 0.16s; }
.korisnici-tabela tbody tr:nth-child(5) { animation-delay: 0.20s; }
.korisnici-tabela tbody tr:nth-child(6) { animation-delay: 0.24s; }
.korisnici-tabela tbody tr:nth-child(7) { animation-delay: 0.28s; }
.korisnici-tabela tbody tr:nth-child(8) { animation-delay: 0.32s; }
.korisnici-tabela tbody tr:nth-child(9) { animation-delay: 0.36s; }
.korisnici-tabela tbody tr:nth-child(10) { animation-delay: 0.40s; }
.nova-forma-kartica {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
animation-delay: 0.30s;
}
</style>
{{end}}
{{define "sadrzaj"}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{if .Sacuvano}}
<div class="poruka-uspeh">Promene su uspešno sačuvane.</div>
<div class="poruka-uspeh poruka-animacija">Promene su uspešno sačuvane.</div>
{{end}}
{{if eq .Greska "1"}}
@@ -21,7 +57,7 @@
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Korisnici sistema</span>
</div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<table class="korisnici-tabela" style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:0.5px solid var(--ivica);">
<th style="padding:10px 20px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Korisničko ime</th>
@@ -87,7 +123,7 @@
</div>
<!-- forma za novog korisnika -->
<div class="kartica">
<div class="kartica nova-forma-kartica">
<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>
+4 -7
View File
@@ -31,18 +31,15 @@
<div style="display:flex;flex-direction:column;gap:12px;">
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Trenutna lozinka</label>
<input type="password" name="stara_lozinka" required
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
<input type="password" name="stara_lozinka" required style="width:100%;">
</div>
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Nova lozinka</label>
<input type="password" name="nova_lozinka" required minlength="8"
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;">
<input type="password" name="nova_lozinka" required minlength="8" style="width:100%;">
</div>
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Potvrda nove lozinke</label>
<input type="password" name="nova_lozinka_potvrda" required
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
<input type="password" name="nova_lozinka_potvrda" required style="width:100%;">
</div>
<div>
<button type="submit"
@@ -85,7 +82,7 @@
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Verifikacioni kod</label>
<input type="text" name="kod" inputmode="numeric" pattern="[0-9]{6}"
maxlength="6" required autofocus
style="width:160px;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:18px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;text-align:center;letter-spacing:4px;">
style="width:160px;font-size:18px;text-align:center;letter-spacing:4px;">
</div>
<button type="submit"
style="padding:9px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">
+43 -8
View File
@@ -2,10 +2,37 @@
{{define "naslov"}}Dashboard — NTech{{end}}
{{define "dodatni-css"}}
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.dash-stat {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.dash-stat:nth-child(1) { animation-delay: 0.04s; }
.dash-stat:nth-child(2) { animation-delay: 0.10s; }
.dash-stat:nth-child(3) { animation-delay: 0.16s; }
.dash-stat:nth-child(4) { animation-delay: 0.22s; }
.dash-stat:nth-child(5) { animation-delay: 0.28s; }
.dash-kartica {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.dash-kartica:nth-child(1) { animation-delay: 0.20s; }
.dash-kartica:nth-child(2) { animation-delay: 0.28s; }
.dash-kartica:nth-child(3) { animation-delay: 0.36s; }
</style>
{{end}}
{{define "sadrzaj"}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
<!-- stat kartice -->
<div class="kartica">
<div class="kartica dash-stat">
<div style="width:36px;height:36px;border-radius:8px;background:#eff2ff;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4f7ef8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
@@ -13,7 +40,7 @@
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:4px;">Artikala na stanju</div>
</div>
<div class="kartica">
<div class="kartica dash-stat">
<div style="width:36px;height:36px;border-radius:8px;background:#f0fdf4;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</div>
@@ -21,7 +48,7 @@
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:4px;">Aktivnih servisa</div>
</div>
<div class="kartica">
<div class="kartica dash-stat">
<div style="width:36px;height:36px;border-radius:8px;background:#fff7ed;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
</div>
@@ -29,18 +56,26 @@
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:4px;">Prihod ovog meseca</div>
</div>
<div class="kartica">
<div class="kartica dash-stat">
<div style="width:36px;height:36px;border-radius:8px;background:#fef2f2;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);">{{.KriticnaZaliha}}</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:4px;">Kritično niska zaliha</div>
</div>
<a href="/podsetnici" class="kartica dash-stat" style="text-decoration:none;display:block;">
<div style="width:36px;height:36px;border-radius:8px;background:#f0f9ff;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0284c7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</div>
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);">{{.AktivniPodsetnici}}</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:4px;">Aktivnih podsetnika</div>
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- poslednji servisi -->
<div class="kartica">
<div class="kartica dash-kartica">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Poslednji servisi</span>
</div>
@@ -56,7 +91,7 @@
</div>
<!-- kritične zalihe -->
<div class="kartica">
<div class="kartica dash-kartica">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Kritične zalihe</span>
<span style="font-size:11px;padding:2px 8px;border-radius:20px;background:#fffbeb;color:#b45309;font-weight:500;">Upozorenje</span>
@@ -73,7 +108,7 @@
</div>
<!-- poslednje prodaje -->
<div class="kartica">
<div class="kartica dash-kartica">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Poslednje prodaje</span>
</div>
+25 -5
View File
@@ -82,6 +82,26 @@
.badge-dana.upozorenje { background: #fffbeb; color: #b45309; }
.badge-dana.kritican { background: #fef2f2; color: #dc2626; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.izv-sekcija {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.izv-sekcija:nth-child(1) { animation-delay: 0.04s; }
.izv-sekcija:nth-child(2) { animation-delay: 0.12s; }
.izv-sekcija:nth-child(3) { animation-delay: 0.20s; }
.izv-grid-kartica {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.izv-grid-kartica:nth-child(1) { animation-delay: 0.24s; }
.izv-grid-kartica:nth-child(2) { animation-delay: 0.32s; }
</style>
{{end}}
@@ -89,7 +109,7 @@
<div style="display:flex;flex-direction:column;gap:20px;">
<!-- 1. mesečni prihod -->
<div class="kartica">
<div class="kartica izv-sekcija">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:4px;">
<div class="izv-naslov" style="margin-bottom:0;">Mesečni prihod — poslednjih 12 meseci</div>
<div class="toggle-red" style="margin-bottom:0;">
@@ -136,7 +156,7 @@
</div>
<!-- 2. stari otvoreni nalozi -->
<div class="kartica">
<div class="kartica izv-sekcija">
<div class="izv-naslov">Stari otvoreni nalozi — bez završetka duže od 14 dana</div>
{{if .StariNalozi}}
<div style="overflow-x:auto;">
@@ -178,10 +198,10 @@
{{end}}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 izv-sekcija">
<!-- 3. top 10 artikala -->
<div class="kartica">
<div class="kartica izv-grid-kartica">
<div class="izv-naslov">Najprodavaniji artikli — top 10</div>
{{if .TopArtikli}}
<table style="width:100%;border-collapse:collapse;">
@@ -215,7 +235,7 @@
</div>
<!-- 4. top 10 klijenata -->
<div class="kartica">
<div class="kartica izv-grid-kartica">
<div class="izv-naslov">Najvažniji klijenti — top 10</div>
{{if .TopKlijenti}}
<table style="width:100%;border-collapse:collapse;">
+38 -4
View File
@@ -2,14 +2,48 @@
{{define "naslov"}}Kategorije — NTech{{end}}
{{define "dodatni-css"}}
<style>
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.poruka-animacija { animation: slideDown 0.3s ease forwards; }
.kat-forma-kartica {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
animation-delay: 0.04s;
}
.kat-red {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.kat-red:nth-child(1) { animation-delay: 0.12s; }
.kat-red:nth-child(2) { animation-delay: 0.18s; }
.kat-red:nth-child(3) { animation-delay: 0.24s; }
.kat-red:nth-child(4) { animation-delay: 0.30s; }
.kat-red:nth-child(5) { animation-delay: 0.36s; }
.kat-red:nth-child(6) { animation-delay: 0.42s; }
.kat-red:nth-child(7) { animation-delay: 0.48s; }
.kat-red:nth-child(8) { animation-delay: 0.54s; }
</style>
{{end}}
{{define "sadrzaj"}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{if .Sacuvano}}
<div class="poruka-uspeh">Kategorija je uspešno sačuvana.</div>
<div class="poruka-uspeh poruka-animacija">Kategorija je uspešno sačuvana.</div>
{{end}}
{{if .Obrisana}}
<div class="poruka-uspeh">Kategorija je uspešno obrisana.</div>
<div class="poruka-uspeh poruka-animacija">Kategorija je uspešno obrisana.</div>
{{end}}
<!-- nazad dugme -->
@@ -19,7 +53,7 @@
</a>
<!-- forma za novu kategoriju -->
<div class="kartica">
<div class="kartica kat-forma-kartica">
<div style="margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Nova kategorija</span>
</div>
@@ -53,7 +87,7 @@
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Postojeće kategorije</span>
</div>
{{range .Kategorije}}
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:0.5px solid var(--ivica);">
<div class="kat-red" style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:0.5px solid var(--ivica);">
<div style="flex:1;">
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</div>
{{if .Opis}}
+25 -1
View File
@@ -2,6 +2,30 @@
{{define "naslov"}}Magacin — NTech{{end}}
{{define "dodatni-css"}}
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.magacin-tabela tbody tr {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.magacin-tabela tbody tr:nth-child(1) { animation-delay: 0.04s; }
.magacin-tabela tbody tr:nth-child(2) { animation-delay: 0.08s; }
.magacin-tabela tbody tr:nth-child(3) { animation-delay: 0.12s; }
.magacin-tabela tbody tr:nth-child(4) { animation-delay: 0.16s; }
.magacin-tabela tbody tr:nth-child(5) { animation-delay: 0.20s; }
.magacin-tabela tbody tr:nth-child(6) { animation-delay: 0.24s; }
.magacin-tabela tbody tr:nth-child(7) { animation-delay: 0.28s; }
.magacin-tabela tbody tr:nth-child(8) { animation-delay: 0.32s; }
.magacin-tabela tbody tr:nth-child(9) { animation-delay: 0.36s; }
.magacin-tabela tbody tr:nth-child(10) { animation-delay: 0.40s; }
</style>
{{end}}
{{define "sadrzaj"}}
<div style="display:flex;flex-direction:column;gap:16px;">
@@ -50,7 +74,7 @@
<!-- tabela artikala -->
<div class="kartica" style="padding:0;overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<table class="magacin-tabela" style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:0.5px solid var(--ivica);">
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Naziv</th>
+45 -23
View File
@@ -11,6 +11,37 @@
</div>
{{end}}
<!-- upload loga — posebna forma jer je multipart, mora biti van glavne forme -->
<form method="POST" action="/podesavanja/logo" enctype="multipart/form-data">
<div class="kartica" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Logo firme</span>
</div>
{{if .LogoGreska}}
<div style="background:rgba(207,87,87,0.1);border:0.5px solid var(--greska);border-radius:8px;padding:8px 12px;margin-bottom:10px;font-size:13px;color:var(--greska);">
{{.LogoGreska}}
</div>
{{end}}
{{if .LogoPutanja}}
<div style="margin-bottom:12px;">
<img src="{{.LogoPutanja}}" alt="Trenutni logo"
style="max-height:60px;max-width:200px;object-fit:contain;border:0.5px solid var(--ivica);border-radius:8px;padding:6px;background:var(--kartica);">
</div>
{{end}}
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<input type="file" name="logo" accept=".png,.jpg,.jpeg,.svg"
style="flex:1;min-width:200px;font-size:13px;color:var(--tekst-sporedni);">
<button type="submit"
style="padding:8px 16px;background:var(--sb-aktivan);color:var(--tekst-jak);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;cursor:pointer;white-space:nowrap;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
Otpremi logo
</button>
</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:6px;">PNG, JPG ili SVG — maksimum 2 MB</div>
</div>
</form>
<form method="POST" action="/podesavanja/sacuvaj">
<!-- sekcija: firma -->
@@ -59,21 +90,22 @@
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:8px;">Logo zona</label>
<div style="display:flex;gap:10px;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .LogoTip "ikonica"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="logo_tip" value="ikonica" {{if eq .LogoTip "ikonica"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Ikonica</span>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if or (eq .LogoTip "sa_nazivom") (eq .LogoTip "tekst") (eq .LogoTip "ikonica") (not .LogoTip)}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="logo_tip" value="sa_nazivom" {{if or (eq .LogoTip "sa_nazivom") (eq .LogoTip "tekst") (eq .LogoTip "ikonica") (not .LogoTip)}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Sa nazivom</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .LogoTip "tekst"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="logo_tip" value="tekst" {{if eq .LogoTip "tekst"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Bez ikonice</span>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .LogoTip "bez_naziva"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="logo_tip" value="bez_naziva" {{if eq .LogoTip "bez_naziva"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Bez naziva</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .LogoTip "slika"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
<input type="radio" name="logo_tip" value="slika" {{if eq .LogoTip "slika"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Slika</span>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;flex:1;{{if eq .LogoTip "slika"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}{{if not .LogoPutanja}}opacity:0.45;cursor:not-allowed;{{else}}cursor:pointer;{{end}}">
<input type="radio" name="logo_tip" value="slika" {{if eq .LogoTip "slika"}}checked{{end}} {{if not .LogoPutanja}}disabled{{end}} style="accent-color:var(--sb-akcent);">
<span style="font-size:13px;color:var(--tekst-glavni);">Sa logom</span>
</label>
</div>
</div>
</div>
</div>
@@ -89,24 +121,14 @@
<div class="teme-grid" style="display:flex;gap:10px;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .Tema "tamna"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="tema" value="tamna" {{if eq .Tema "tamna"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<div style="width:16px;height:16px;border-radius:50%;background:#1a1d2e;flex-shrink:0;"></div>
<div style="width:16px;height:16px;border-radius:50%;background:#1a1d27;flex-shrink:0;"></div>
<span style="font-size:13px;color:var(--tekst-glavni);">Tamna</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .Tema "svetla"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="tema" value="svetla" {{if eq .Tema "svetla"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<div style="width:16px;height:16px;border-radius:50%;background:#f8fafc;border:0.5px solid #cbd5e1;flex-shrink:0;"></div>
<div style="width:16px;height:16px;border-radius:50%;background:#f0f2f5;border:0.5px solid #e2e6ed;flex-shrink:0;"></div>
<span style="font-size:13px;color:var(--tekst-glavni);">Svetla</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .Tema "zelena"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="tema" value="zelena" {{if eq .Tema "zelena"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<div style="width:16px;height:16px;border-radius:50%;background:#3a975c;flex-shrink:0;"></div>
<span style="font-size:13px;color:var(--tekst-glavni);">Zelena</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .Tema "ljubicasta"}}border-color:var(--sb-akcent);{{end}}">
<input type="radio" name="tema" value="ljubicasta" {{if eq .Tema "ljubicasta"}}checked{{end}} style="accent-color:var(--sb-akcent);">
<div style="width:16px;height:16px;border-radius:50%;background:#462f80;flex-shrink:0;"></div>
<span style="font-size:13px;color:var(--tekst-glavni);">Ljubičasta</span>
</label>
</div>
</div>
</div>
@@ -118,7 +140,7 @@
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Sistem</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
<div style="font-size:13px;color:var(--tekst-sporedni);">Verzija programa: <span style="color:var(--tekst-glavni);font-weight:500;">1.0.0</span></div>
<div style="font-size:13px;color:var(--tekst-sporedni);">Verzija programa: <span style="color:var(--tekst-glavni);font-weight:500;">{{.Verzija}}</span></div>
<a href="/podesavanja/backup"
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);text-decoration:none;transition:background 0.2s;"
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
+217
View File
@@ -0,0 +1,217 @@
{{template "base" .}}
{{define "naslov"}}Podsetnici — NTech{{end}}
{{define "dodatni-css"}}
<style>
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.poruka-animacija {
animation: slideDown 0.3s ease forwards;
}
.pod-tabela tbody tr {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.pod-tabela tbody tr:nth-child(1) { animation-delay: 0.04s; }
.pod-tabela tbody tr:nth-child(2) { animation-delay: 0.08s; }
.pod-tabela tbody tr:nth-child(3) { animation-delay: 0.12s; }
.pod-tabela tbody tr:nth-child(4) { animation-delay: 0.16s; }
.pod-tabela tbody tr:nth-child(5) { animation-delay: 0.20s; }
.pod-tabela tbody tr:nth-child(6) { animation-delay: 0.24s; }
.pod-tabela tbody tr:nth-child(7) { animation-delay: 0.28s; }
.pod-tabela tbody tr:nth-child(8) { animation-delay: 0.32s; }
.pod-tabela tbody tr:nth-child(9) { animation-delay: 0.36s; }
.pod-tabela tbody tr:nth-child(10) { animation-delay: 0.40s; }
.pod-kartice {
display: none;
flex-direction: column;
gap: 12px;
}
.pod-kartica {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.pod-kartica:nth-child(1) { animation-delay: 0.04s; }
.pod-kartica:nth-child(2) { animation-delay: 0.10s; }
.pod-kartica:nth-child(3) { animation-delay: 0.16s; }
.pod-kartica:nth-child(4) { animation-delay: 0.22s; }
.pod-kartica:nth-child(5) { animation-delay: 0.28s; }
.red-prekoracen td {
background: rgba(207, 87, 87, 0.06);
}
@media (max-width: 768px) {
.pod-tabela { display: none; }
.pod-kartice { display: flex; }
}
</style>
{{end}}
{{define "sadrzaj"}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{if .Sacuvano}}
<div class="poruka-uspeh poruka-animacija">Podsetnik je uspešno sačuvan.</div>
{{end}}
{{if .Obrisan}}
<div class="poruka-uspeh poruka-animacija">Podsetnik je uspešno obrisan.</div>
{{end}}
<!-- gornja traka -->
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<a href="/podsetnici/novi"
style="padding:8px 16px;background:var(--sb-akcent);color:#fff;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none;white-space:nowrap;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">
+ Novi podsetnik
</a>
<form method="GET" action="/podsetnici" style="display:flex;gap:8px;align-items:center;flex:1;min-width:200px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:6px;font-size:14px;color:var(--tekst-sporedni);cursor:pointer;white-space:nowrap;">
<input type="checkbox" name="samo_aktivni" value="1" {{if .SamoAktivni}}checked{{end}}
onchange="this.form.submit()"
style="width:auto;padding:0;border:none !important;background:none !important;">
Samo aktivni
</label>
</form>
</div>
<!-- desktop tabela -->
<div class="kartica pod-tabela" style="padding:0;overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:0.5px solid var(--ivica);">
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Naslov</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Datum podsećanja</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Status</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Akcije</th>
</tr>
</thead>
<tbody>
{{range .Podsetnici}}
<tr class="{{if .JePrekoracen}}red-prekoracen{{end}}"
style="border-bottom:0.5px solid var(--ivica);transition:background 0.15s;"
onmouseover="this.style.background='var(--pozadina)'"
onmouseout="this.style.background=''">
<td style="padding:12px 16px;">
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{.Naslov}}</div>
{{if .Napomena}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.Napomena}}</div>
{{end}}
</td>
<td style="padding:12px 16px;font-size:13px;color:{{if .JePrekoracen}}var(--greska){{else}}var(--tekst-sporedni){{end}};">
{{.DatumPodsecanja.Format "02.01.2006."}}
</td>
<td style="padding:12px 16px;">
{{if .Zavrseno}}
<span style="display:inline-block;padding:3px 10px;background:var(--pozadina-hover);color:var(--tekst-sporedni);border-radius:20px;font-size:12px;">Završeno</span>
{{else if .JePrekoracen}}
<span style="display:inline-block;padding:3px 10px;background:rgba(207,87,87,0.12);color:var(--greska);border-radius:20px;font-size:12px;">Prekoračeno</span>
{{else}}
<span style="display:inline-block;padding:3px 10px;background:rgba(93,184,118,0.12);color:var(--uspeh);border-radius:20px;font-size:12px;">Aktivno</span>
{{end}}
</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
<a href="/podsetnici/izmeni/{{.ID}}"
style="padding:4px 10px;background:var(--sb-aktivan);color:var(--tekst-jak);border-radius:6px;font-size:12px;text-decoration:none;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
Izmeni
</a>
<form method="POST" action="/podsetnici/zavrseno/{{.ID}}" style="display:inline;">
<button type="submit"
style="padding:4px 10px;background:var(--sb-aktivan);color:var(--tekst-jak);border:none;border-radius:6px;font-size:12px;cursor:pointer;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
{{if .Zavrseno}}Aktiviraj{{else}}Završi{{end}}
</button>
</form>
<form method="POST" action="/podsetnici/obrisi/{{.ID}}" style="display:inline;">
<button type="submit"
onclick="return confirm('Da li ste sigurni da želite da obrišete podsetnik?')"
style="padding:4px 10px;background:#dc2626;color:#fff;border:none;border-radius:6px;font-size:12px;cursor:pointer;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
Obriši
</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="4" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
Nema podsetnika. <a href="/podsetnici/novi" style="color:var(--sb-akcent);">Dodaj prvi podsetnik.</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- mobilne kartice -->
<div class="pod-kartice">
{{range .Podsetnici}}
<div class="kartica pod-kartica">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px;">
<div>
<div style="font-size:15px;font-weight:500;color:{{if .JePrekoracen}}var(--greska){{else}}var(--tekst-glavni){{end}};">{{.Naslov}}</div>
{{if .Napomena}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.Napomena}}</div>
{{end}}
</div>
<div>
{{if .Zavrseno}}
<span style="display:inline-block;padding:3px 10px;background:var(--pozadina-hover);color:var(--tekst-sporedni);border-radius:20px;font-size:12px;">Završeno</span>
{{else if .JePrekoracen}}
<span style="display:inline-block;padding:3px 10px;background:rgba(207,87,87,0.12);color:var(--greska);border-radius:20px;font-size:12px;">Prekoračeno</span>
{{else}}
<span style="display:inline-block;padding:3px 10px;background:rgba(93,184,118,0.12);color:var(--uspeh);border-radius:20px;font-size:12px;">Aktivno</span>
{{end}}
</div>
</div>
<div style="font-size:13px;color:var(--tekst-sporedni);margin-bottom:12px;">
{{.DatumPodsecanja.Format "02.01.2006."}}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a href="/podsetnici/izmeni/{{.ID}}"
style="padding:6px 14px;background:var(--sb-aktivan);color:var(--tekst-jak);border-radius:6px;font-size:13px;text-decoration:none;">
Izmeni
</a>
<form method="POST" action="/podsetnici/zavrseno/{{.ID}}" style="display:inline;">
<button type="submit"
style="padding:6px 14px;background:var(--sb-aktivan);color:var(--tekst-jak);border:none;border-radius:6px;font-size:13px;cursor:pointer;">
{{if .Zavrseno}}Aktiviraj{{else}}Završi{{end}}
</button>
</form>
<form method="POST" action="/podsetnici/obrisi/{{.ID}}" style="display:inline;">
<button type="submit"
onclick="return confirm('Da li ste sigurni da želite da obrišete podsetnik?')"
style="padding:6px 14px;background:#dc2626;color:#fff;border:none;border-radius:6px;font-size:13px;cursor:pointer;">
Obriši
</button>
</form>
</div>
</div>
{{else}}
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
Nema podsetnika. <a href="/podsetnici/novi" style="color:var(--sb-akcent);">Dodaj prvi podsetnik.</a>
</div>
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,99 @@
{{template "base" .}}
{{define "naslov"}}{{if .Izmena}}Izmeni podsetnik{{else}}Novi podsetnik{{end}} — NTech{{end}}
{{define "dodatni-css"}}
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
.forma-kartica {
animation: fadeInUp 0.3s ease forwards;
}
.greska-animacija {
animation: shake 0.4s ease;
}
</style>
{{end}}
{{define "sadrzaj"}}
<div style="width:100%;">
<a href="/podsetnici"
style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--tekst-sporedni);text-decoration:none;margin-bottom:20px;transition:color 0.2s;"
onmouseover="this.style.color='var(--tekst-glavni)'" onmouseout="this.style.color='var(--tekst-sporedni)'">
<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 podsetnik
</a>
<div class="kartica forma-kartica" style="max-width:600px;">
<div style="margin-bottom:20px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">
{{if .Izmena}}Izmeni podsetnik{{else}}Novi podsetnik{{end}}
</span>
</div>
{{if .Greska}}
<div class="greska-animacija"
style="background:var(--kartica);border:0.5px solid var(--greska);border-radius:8px;padding:10px 14px;margin-bottom:16px;font-size:13px;color:var(--greska);">
{{.Greska}}
</div>
{{end}}
<form method="POST" action="{{if .Izmena}}/podsetnici/izmeni/{{.Podsetnik.ID}}{{else}}/podsetnici/novi{{end}}">
<div style="display:flex;flex-direction:column;gap:16px;">
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">
Naslov <span style="color:var(--greska);">*</span>
</label>
<input type="text" name="naslov" value="{{.Podsetnik.Naslov}}"
placeholder="npr. Obnoviti ugovor sa dobavljačem"
style="width:100%;" autofocus>
</div>
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">
Datum podsećanja <span style="color:var(--greska);">*</span>
</label>
<input type="date" name="datum_podsecanja"
value="{{if not .Podsetnik.DatumPodsecanja.IsZero}}{{.Podsetnik.DatumPodsecanja.Format "2006-01-02"}}{{end}}"
style="width:100%;">
</div>
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Napomena</label>
<textarea name="napomena" rows="4"
placeholder="Detalji ili beleška vezana za ovaj podsetnik..."
style="width:100%;resize:vertical;">{{.Podsetnik.Napomena}}</textarea>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:4px;">
<a href="/podsetnici"
style="padding:9px 20px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;color:var(--tekst-sporedni);text-decoration:none;transition:background 0.2s;"
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background=''">
Odustani
</a>
<button type="submit"
style="padding:9px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">
{{if .Izmena}}Sačuvaj izmene{{else}}Dodaj podsetnik{{end}}
</button>
</div>
</div>
</form>
</div>
</div>
{{end}}
+6 -6
View File
@@ -16,8 +16,8 @@
padding: 16px;
}
.kartica {
background: #1a1d2e;
border: 0.5px solid #2a2d3e;
background: #1a1d27;
border: 0.5px solid #2d3148;
border-radius: 16px;
padding: 40px;
width: 100%;
@@ -53,20 +53,20 @@
}
input {
width: 100%;
padding: 10px 14px;
padding: 8px 12px;
background: #0f1117;
border: 0.5px solid #2a2d3e;
border: 0.5px solid #2d3148;
border-radius: 8px;
font-size: 14px;
color: #fff;
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: #4f7ef8; }
input:focus { border-color: #e53e3e; }
.dugme {
width: 100%;
padding: 11px;
background: #4f7ef8;
background: #e53e3e;
color: #fff;
border: none;
border-radius: 8px;
+6 -6
View File
@@ -16,8 +16,8 @@
padding: 16px;
}
.kartica {
background: #1a1d2e;
border: 0.5px solid #2a2d3e;
background: #1a1d27;
border: 0.5px solid #2d3148;
border-radius: 16px;
padding: 40px;
width: 100%;
@@ -32,20 +32,20 @@
label { display: block; font-size: 13px; color: #9ca3af; margin-bottom: 6px; }
input {
width: 100%;
padding: 10px 14px;
padding: 8px 12px;
background: #0f1117;
border: 0.5px solid #2a2d3e;
border: 0.5px solid #2d3148;
border-radius: 8px;
font-size: 14px;
color: #fff;
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: #4f7ef8; }
input:focus { border-color: #e53e3e; }
.dugme {
width: 100%;
padding: 11px;
background: #4f7ef8;
background: #e53e3e;
color: #fff;
border: none;
border-radius: 8px;
+6 -6
View File
@@ -16,8 +16,8 @@
padding: 16px;
}
.kartica {
background: #1a1d2e;
border: 0.5px solid #2a2d3e;
background: #1a1d27;
border: 0.5px solid #2d3148;
border-radius: 16px;
padding: 40px;
width: 100%;
@@ -31,9 +31,9 @@
label { display: block; font-size: 13px; color: #9ca3af; margin-bottom: 6px; }
input {
width: 100%;
padding: 10px 14px;
padding: 8px 12px;
background: #0f1117;
border: 0.5px solid #2a2d3e;
border: 0.5px solid #2d3148;
border-radius: 8px;
font-size: 20px;
color: #fff;
@@ -42,11 +42,11 @@
letter-spacing: 6px;
transition: border-color 0.2s;
}
input:focus { border-color: #4f7ef8; }
input:focus { border-color: #e53e3e; }
.dugme {
width: 100%;
padding: 11px;
background: #4f7ef8;
background: #e53e3e;
color: #fff;
border: none;
border-radius: 8px;