Dodavanje modula prodaje — lista, forma, detalji, provera stanja, vraćanje pri brisanju

This commit is contained in:
2026-06-02 17:53:50 +02:00
parent d3b4bb329d
commit def84e1a69
10 changed files with 1249 additions and 0 deletions
+5
View File
@@ -90,6 +90,11 @@ func main() {
r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga) r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
r.Post("/servis/obrisi/{id}", h.ObrisiNalog) r.Post("/servis/obrisi/{id}", h.ObrisiNalog)
r.Get("/servis/{id}", h.DetaljiNaloga) r.Get("/servis/{id}", h.DetaljiNaloga)
r.Get("/prodaja", h.Prodaja)
r.Get("/prodaja/nova", h.NovaProdaja)
r.Post("/prodaja/nova", h.SacuvajProdaju)
r.Post("/prodaja/obrisi/{id}", h.ObrisiProdaju)
r.Get("/prodaja/{id}", h.DetaljiProdaje)
log.Printf("NTech pokrenut na portu %s", port) log.Printf("NTech pokrenut na portu %s", port)
err = http.ListenAndServe(":"+port, r) err = http.ListenAndServe(":"+port, r)
+10
View File
@@ -0,0 +1,10 @@
package db
// ErrNedovoljnoKolicine se vraća kada prodaja traži više nego što ima na stanju
type ErrNedovoljnoKolicine struct {
ArtikalNaziv string
}
func (e *ErrNedovoljnoKolicine) Error() string {
return "Nedovoljno količine na stanju za artikal: " + e.ArtikalNaziv
}
+10
View File
@@ -64,3 +64,13 @@ type ServisRepository interface {
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
SledeciBroj(ctx context.Context) (string, error) SledeciBroj(ctx context.Context) (string, error)
} }
// ProdajaRepository definiše operacije nad prodajnim nalozima
type ProdajaRepository interface {
Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error)
DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error)
DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error)
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje) (int64, error)
Obrisi(ctx context.Context, id int64) error
SledeciBroj(ctx context.Context) (string, error)
}
+262
View File
@@ -0,0 +1,262 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"ntech/internal/db"
"ntech/internal/model"
)
// ProdajaRepo je SQLite implementacija ProdajaRepository interfejsa
type ProdajaRepo struct {
db *sql.DB
}
// NoviProdajaRepo kreira novi ProdajaRepo
func NoviProdajaRepo(db *sql.DB) *ProdajaRepo {
return &ProdajaRepo{db: db}
}
// SledeciBroj generiše sledeći broj naloga u formatu PR-GGGG-NNNN
func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
godina := time.Now().Year()
uzorak := fmt.Sprintf("PR-%d-%%", godina)
var sledeci int
err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
FROM prodajni_nalozi
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
if err != nil {
return "", fmt.Errorf("ntech: ProdajaRepo.SledeciBroj: %w", err)
}
return fmt.Sprintf("PR-%d-%04d", godina, sledeci), nil
}
// Lista vraća listu prodajnih naloga sa imenom klijenta, opcionom pretragom
func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error) {
upit := `
SELECT
pn.id, pn.klijent_id, pn.broj_naloga, pn.napomena, pn.ukupno, pn.datum,
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv
FROM prodajni_nalozi pn
LEFT JOIN klijenti k ON k.id = pn.klijent_id
WHERE 1=1`
args := []any{}
if pretraga != "" {
upit += " AND pn.broj_naloga LIKE ?"
args = append(args, "%"+pretraga+"%")
}
upit += " ORDER BY pn.datum DESC"
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.ProdajniNalogSaDetaljem
for redovi.Next() {
var n model.ProdajniNalogSaDetaljem
var klijentID sql.NullInt64
var napomena sql.NullString
err := redovi.Scan(
&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno, &n.Datum,
&n.KlijentNaziv,
)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.Lista: scan: %w", err)
}
if klijentID.Valid {
v := klijentID.Int64
n.KlijentID = &v
}
n.Napomena = napomena.String
rezultat = append(rezultat, n)
}
return rezultat, nil
}
// DohvatiID vraća jedan prodajni nalog po ID-u
func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error) {
red := r.db.QueryRowContext(ctx, `
SELECT id, klijent_id, broj_naloga, napomena, ukupno, datum
FROM prodajni_nalozi WHERE id = ?`, id)
var n model.ProdajniNalog
var klijentID sql.NullInt64
var napomena sql.NullString
err := red.Scan(&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno, &n.Datum)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiID: %w", err)
}
if klijentID.Valid {
v := klijentID.Int64
n.KlijentID = &v
}
n.Napomena = napomena.String
return &n, nil
}
// DohvatiStavke vraća stavke prodaje sa nazivima artikala za dati nalog
func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno,
a.naziv
FROM stavke_prodaje sp
JOIN artikli a ON a.id = sp.artikal_id
WHERE sp.nalog_id = ?
ORDER BY sp.id`, nalogID)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: %w", err)
}
defer redovi.Close()
var stavke []model.StavkaProdajeSaArtiklom
for redovi.Next() {
var s model.StavkaProdajeSaArtiklom
err := redovi.Scan(
&s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
&s.CenaPoKomadu, &s.Ukupno, &s.ArtikalNaziv,
)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err)
}
stavke = append(stavke, s)
}
return stavke, nil
}
// Kreiraj upisuje novi prodajni nalog u bazu u okviru jedne transakcije.
// Za svaku stavku proverava stanje u magacinu i smanjuje ga.
// Ako bilo koji artikal nema dovoljno stanja, vraća ErrNedovoljnoKolicine.
func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje) (int64, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: begin tx: %w", err)
}
defer tx.Rollback()
// provera i smanjenje stanja za svaku stavku
for _, s := range stavke {
var naziv string
var kolicinaNaStanju int
err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &kolicinaNaStanju)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
}
if kolicinaNaStanju < s.Kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = kolicina - ? WHERE id = ?",
s.Kolicina, s.ArtikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
}
}
// insert zaglavlja naloga
rezultat, err := tx.ExecContext(ctx, `
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, datum)
VALUES (?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.Datum,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert nalog: %w", err)
}
nalogID, err := rezultat.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: last insert id: %w", err)
}
// insert stavki
for _, s := range stavke {
ukupnoStavke := float64(s.Kolicina) * s.CenaPoKomadu
_, err := tx.ExecContext(ctx, `
INSERT INTO stavke_prodaje (nalog_id, artikal_id, kolicina, cena_po_komadu, ukupno)
VALUES (?, ?, ?, ?, ?)`,
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, ukupnoStavke,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: commit: %w", err)
}
return nalogID, nil
}
// Obrisi briše prodajni nalog i vraća količine artikala na stanje (u transakciji)
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: begin tx: %w", err)
}
defer tx.Rollback()
// vraćanje stanja u magacin
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stavke: %w", err)
}
type povrat struct{ artikalID int64; kolicina int }
var stavke []povrat
for redovi.Next() {
var p povrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: scan stavke: %w", err)
}
stavke = append(stavke, p)
}
redovi.Close()
for _, p := range stavke {
_, err := tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?",
p.kolicina, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err)
}
}
// CASCADE briše i stavke_prodaje
_, err = tx.ExecContext(ctx, "DELETE FROM prodajni_nalozi WHERE id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: delete: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: commit: %w", err)
}
return nil
}
+2
View File
@@ -16,6 +16,7 @@ type Handler struct {
NabavkeRepo db.NabavkaRepository NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository ServisRepo db.ServisRepository
ProdajaRepo db.ProdajaRepository
} }
// Novi kreira novi Handler sa datom bazom // Novi kreira novi Handler sa datom bazom
@@ -28,5 +29,6 @@ func Novi(baza *sql.DB) *Handler {
NabavkeRepo: sqlite.NoviNabavkaRepo(baza), NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza), KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza), ServisRepo: sqlite.NoviServisRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
} }
} }
+375
View File
@@ -0,0 +1,375 @@
package handler
import (
"encoding/json"
"errors"
"html/template"
"log"
"net/http"
"strconv"
"strings"
"time"
appdb "ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// PodaciProdaje su podaci za stranicu sa listom prodajnih naloga
type PodaciProdaje struct {
model.PodaciStranice
Nalozi []model.ProdajniNalogSaDetaljem
Sacuvano bool
Obrisan bool
Pretraga string
}
// PodaciFormeProdaje su podaci za formu unosa nove prodaje
type PodaciFormeProdaje struct {
model.PodaciStranice
Artikli []model.ArtikalSaKategorijom
ArtikliJSON template.JS
Klijenti []model.Klijent
Greska string
}
// PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama
type PodaciDetaljiProdaje struct {
model.PodaciStranice
Nalog model.ProdajniNalog
Stavke []model.StavkaProdajeSaArtiklom
KlijentNaziv string
Sacuvano bool
}
// artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom
func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS {
type stavka struct {
ID int64 `json:"id"`
Naziv string `json:"naziv"`
Cena float64 `json:"cena"`
}
lista := make([]stavka, 0, len(artikli))
for _, a := range artikli {
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, Cena: a.ProdajnaCena})
}
b, _ := json.Marshal(lista)
return template.JS(b)
}
// Prodaja renderuje listu svih prodajnih naloga
func (h *Handler) Prodaja(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
}
pretraga := strings.TrimSpace(r.URL.Query().Get("pretraga"))
nalozi, err := h.ProdajaRepo.Lista(r.Context(), pretraga)
if err != nil {
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
return
}
podaci := PodaciProdaje{
PodaciStranice: model.PodaciStranice{
Stranica: "prodaja",
NaslovStranice: "Prodaja",
Tema: podesavanja["tema"],
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin",
},
Nalozi: nalozi,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1",
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)
}
}
// NovaProdaja prikazuje formu za unos novog prodajnog naloga
func (h *Handler) NovaProdaja(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
}
artikli, err := h.Artikli.Lista(r.Context(), appdb.ArtikalFilter{})
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
}
klijenti, err := h.KlijentiRepo.Lista(r.Context(), "")
if err != nil {
http.Error(w, "Greška pri učitavanju klijenata", http.StatusInternalServerError)
return
}
renderujFormuProdaje(w, PodaciFormeProdaje{
PodaciStranice: model.PodaciStranice{
Stranica: "prodaja",
NaslovStranice: "Nova prodaja",
Tema: podesavanja["tema"],
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin",
},
Artikli: artikli,
ArtikliJSON: artikalUJSONSaCenom(artikli),
Klijenti: klijenti,
})
}
// SacuvajProdaju prima POST formu, parsira stavke i upisuje prodajni nalog u bazu
func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
nalog, stavke, greska := parseFormuProdaje(r)
renderujGresku := func(poruka string) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
artikli, _ := h.Artikli.Lista(r.Context(), appdb.ArtikalFilter{})
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
renderujFormuProdaje(w, PodaciFormeProdaje{
PodaciStranice: model.PodaciStranice{
Stranica: "prodaja",
NaslovStranice: "Nova prodaja",
Tema: podesavanja["tema"],
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin",
},
Artikli: artikli,
ArtikliJSON: artikalUJSONSaCenom(artikli),
Klijenti: klijenti,
Greska: poruka,
})
}
if greska != "" {
renderujGresku(greska)
return
}
brojNaloga, err := h.ProdajaRepo.SledeciBroj(r.Context())
if err != nil {
log.Printf("greška pri generisanju broja naloga: %v", err)
renderujGresku("Greška pri generisanju broja naloga.")
return
}
nalog.BrojNaloga = brojNaloga
nalog.Datum = time.Now()
var ukupno float64
for _, s := range stavke {
ukupno += float64(s.Kolicina) * s.CenaPoKomadu
}
nalog.Ukupno = ukupno
id, err := h.ProdajaRepo.Kreiraj(r.Context(), &nalog, stavke)
if err != nil {
var errStanje *appdb.ErrNedovoljnoKolicine
if errors.As(err, &errStanje) {
renderujGresku(errStanje.Error())
} else {
log.Printf("greška pri čuvanju prodaje: %v", err)
renderujGresku("Greška pri čuvanju prodajnog naloga.")
}
return
}
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// DetaljiProdaje prikazuje pregled jednog prodajnog naloga sa svim stavkama
func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
nalog, err := h.ProdajaRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "Nalog nije pronađen", http.StatusNotFound)
return
}
stavke, err := h.ProdajaRepo.DohvatiStavke(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju stavki", http.StatusInternalServerError)
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
}
klijentNaziv := ""
if nalog.KlijentID != nil {
klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
if err == nil {
if klijent.NazivFirme != "" {
klijentNaziv = klijent.NazivFirme
} else {
klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime)
}
}
}
podaci := PodaciDetaljiProdaje{
PodaciStranice: model.PodaciStranice{
Stranica: "prodaja",
NaslovStranice: "Detalji prodaje",
Tema: podesavanja["tema"],
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin",
},
Nalog: *nalog,
Stavke: stavke,
KlijentNaziv: klijentNaziv,
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)
}
}
// ObrisiProdaju prima POST zahtev, vraća stanje na magacin i briše nalog
func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/prodaja?obrisan=1", http.StatusSeeOther)
}
// parseFormuProdaje čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku
func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProdaje, string) {
var nalog model.ProdajniNalog
if klijentIDStr := r.FormValue("klijent_id"); klijentIDStr != "" {
id, err := strconv.ParseInt(klijentIDStr, 10, 64)
if err == nil {
nalog.KlijentID = &id
}
}
nalog.Napomena = strings.TrimSpace(r.FormValue("napomena"))
artikalIDovi := r.Form["artikal_id[]"]
kolicine := r.Form["kolicina[]"]
cene := r.Form["cena_po_komadu[]"]
if len(artikalIDovi) == 0 {
return nalog, nil, "Prodaja mora imati najmanje jednu stavku."
}
if len(artikalIDovi) != len(kolicine) || len(artikalIDovi) != len(cene) {
return nalog, nil, "Greška u podacima forme — broj stavki nije ispravan."
}
var stavke []model.StavkaProdaje
for i := range artikalIDovi {
artikalID, err := strconv.ParseInt(strings.TrimSpace(artikalIDovi[i]), 10, 64)
if err != nil || artikalID <= 0 {
return nalog, nil, "Neispravan artikal u stavci."
}
kolicina, err := strconv.Atoi(strings.TrimSpace(kolicine[i]))
if err != nil || kolicina <= 0 {
return nalog, nil, "Količina mora biti pozitivan broj."
}
cena, err := strconv.ParseFloat(strings.TrimSpace(cene[i]), 64)
if err != nil || cena < 0 {
return nalog, nil, "Cena mora biti pozitivan broj."
}
stavke = append(stavke, model.StavkaProdaje{
ArtikalID: artikalID,
Kolicina: kolicina,
CenaPoKomadu: cena,
})
}
return nalog, stavke, ""
}
// 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)
}
}
+35
View File
@@ -0,0 +1,35 @@
package model
import "time"
// ProdajniNalog predstavlja zaglavlje jedne prodaje
type ProdajniNalog struct {
ID int64
KlijentID *int64
BrojNaloga string
Napomena string
Ukupno float64
Datum time.Time
}
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
type StavkaProdaje struct {
ID int64
NalogID int64
ArtikalID int64
Kolicina int
CenaPoKomadu float64
Ukupno float64
}
// ProdajniNalogSaDetaljem je nalog sa nazivom klijenta — za prikaz u listi
type ProdajniNalogSaDetaljem struct {
ProdajniNalog
KlijentNaziv string
}
// StavkaProdajeSaArtiklom je stavka prodaje sa nazivom artikla — za prikaz u detaljima
type StavkaProdajeSaArtiklom struct {
StavkaProdaje
ArtikalNaziv string
}
+184
View File
@@ -0,0 +1,184 @@
{{template "base" .}}
{{define "naslov"}}Prodaja — 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; }
.prodaja-tabela tbody tr {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.prodaja-tabela tbody tr:nth-child(1) { animation-delay: 0.04s; }
.prodaja-tabela tbody tr:nth-child(2) { animation-delay: 0.08s; }
.prodaja-tabela tbody tr:nth-child(3) { animation-delay: 0.12s; }
.prodaja-tabela tbody tr:nth-child(4) { animation-delay: 0.16s; }
.prodaja-tabela tbody tr:nth-child(5) { animation-delay: 0.20s; }
.prodaja-tabela tbody tr:nth-child(6) { animation-delay: 0.24s; }
.prodaja-tabela tbody tr:nth-child(7) { animation-delay: 0.28s; }
.prodaja-tabela tbody tr:nth-child(8) { animation-delay: 0.32s; }
.prodaja-tabela tbody tr:nth-child(9) { animation-delay: 0.36s; }
.prodaja-tabela tbody tr:nth-child(10) { animation-delay: 0.40s; }
.prodaja-kartice {
display: none;
flex-direction: column;
gap: 12px;
}
.prodaja-kartica {
animation: fadeInUp 0.25s ease forwards;
opacity: 0;
}
.prodaja-kartica:nth-child(1) { animation-delay: 0.04s; }
.prodaja-kartica:nth-child(2) { animation-delay: 0.10s; }
.prodaja-kartica:nth-child(3) { animation-delay: 0.16s; }
.prodaja-kartica:nth-child(4) { animation-delay: 0.22s; }
.prodaja-kartica:nth-child(5) { animation-delay: 0.28s; }
@media (max-width: 768px) {
.prodaja-tabela { display: none; }
.prodaja-kartice { display: flex; }
}
</style>
{{end}}
{{define "sadrzaj"}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{if .Sacuvano}}
<div class="poruka-uspeh poruka-animacija">Prodaja je uspešno sačuvana.</div>
{{end}}
{{if .Obrisan}}
<div class="poruka-uspeh poruka-animacija">Prodajni nalog je uspešno obrisan.</div>
{{end}}
<!-- zaglavlje sa pretragom i dugmetom -->
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<form method="GET" action="/prodaja" style="display:flex;gap:8px;flex:1;min-width:200px;">
<input type="text" name="pretraga" value="{{.Pretraga}}"
placeholder="Pretraži po broju naloga..."
style="flex:1;">
<button type="submit"
style="padding:8px 14px;background:var(--kartica);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;white-space:nowrap;transition:background 0.2s;"
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
Pretraži
</button>
{{if .Pretraga}}
<a href="/prodaja"
style="padding:8px 14px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;color:var(--tekst-sporedni);text-decoration:none;white-space:nowrap;transition:background 0.2s;"
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background=''">
✕ Resetuj
</a>
{{end}}
</form>
<a href="/prodaja/nova"
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'">
+ Nova prodaja
</a>
</div>
<!-- desktop tabela -->
<div class="kartica prodaja-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);">Broj naloga</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Datum</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Klijent</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Ukupno</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 .Nalozi}}
<tr 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;font-size:13px;font-family:monospace;color:var(--tekst-glavni);">
{{.BrojNaloga}}
</td>
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);white-space:nowrap;">
{{.Datum.Format "02.01.2006."}}
</td>
<td style="padding:12px 16px;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}}
</td>
<td style="padding:12px 16px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{printf "%.2f" .Ukupno}} din
</td>
<td style="padding:12px 16px;text-align:center;">
<a href="/prodaja/{{.ID}}"
style="padding:4px 10px;background:var(--sb-aktivan);color:#fff;border-radius:6px;font-size:12px;text-decoration:none;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
Detalji
</a>
</td>
</tr>
{{else}}
<tr>
<td colspan="5" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
{{if $.Pretraga}}
Nema naloga koji odgovaraju pretrazi.
{{else}}
Nema prodajnih naloga. <a href="/prodaja/nova" style="color:var(--sb-akcent);">Dodaj prvu prodaju.</a>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- mobilne kartice -->
<div class="prodaja-kartice">
{{range .Nalozi}}
<div class="kartica prodaja-kartica">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px;">
<div>
<div style="font-size:13px;font-family:monospace;color:var(--tekst-glavni);">{{.BrojNaloga}}</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-top:2px;">
{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}Bez klijenta{{end}}
</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">
{{.Datum.Format "02.01.2006."}}
</div>
</div>
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);white-space:nowrap;">
{{printf "%.2f" .Ukupno}} din
</div>
</div>
<a href="/prodaja/{{.ID}}"
style="display:block;padding:7px;background:var(--sb-aktivan);color:#fff;border-radius:6px;font-size:13px;text-decoration:none;text-align:center;">
Detalji
</a>
</div>
{{else}}
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
{{if $.Pretraga}}
Nema naloga koji odgovaraju pretrazi.
{{else}}
Nema prodajnih naloga. <a href="/prodaja/nova" style="color:var(--sb-akcent);">Dodaj prvu prodaju.</a>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
+128
View File
@@ -0,0 +1,128 @@
{{template "base" .}}
{{define "naslov"}}Detalji prodaje — NTech{{end}}
{{define "dodatni-css"}}
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.detalji-kartica { animation: fadeInUp 0.3s ease forwards; opacity: 0; }
.detalji-kartica:nth-child(1) { animation-delay: 0.04s; }
.detalji-kartica:nth-child(2) { animation-delay: 0.12s; }
.detalji-kartica:nth-child(3) { animation-delay: 0.20s; }
.poruka-animacija { animation: slideDown 0.3s ease forwards; }
</style>
{{end}}
{{define "sadrzaj"}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{if .Sacuvano}}
<div class="poruka-uspeh poruka-animacija">Prodaja je uspešno sačuvana.</div>
{{end}}
<!-- nazad dugme -->
<a href="/prodaja"
style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--tekst-sporedni);text-decoration:none;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 prodaju
</a>
<!-- zaglavlje naloga -->
<div class="kartica detalji-kartica">
<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;">
{{.Nalog.BrojNaloga}}
</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(140px, 1fr));gap:16px;">
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Datum prodaje</div>
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.Datum.Format "02.01.2006. u 15:04"}}</div>
</div>
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Klijent</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}}
</div>
</div>
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ukupno</div>
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">{{printf "%.2f" .Nalog.Ukupno}} din</div>
</div>
{{if .Nalog.Napomena}}
<div style="grid-column:1/-1;">
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Napomena</div>
<div style="font-size:13px;color:var(--tekst-sporedni);">{{.Nalog.Napomena}}</div>
</div>
{{end}}
</div>
</div>
<!-- stavke -->
<div class="kartica detalji-kartica" style="padding:0;overflow:hidden;">
<div style="padding:16px 20px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Stavke</span>
</div>
<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:10px 20px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Artikal</th>
<th style="padding:10px 20px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:90px;">Količina</th>
<th style="padding:10px 20px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:140px;">Cena/kom</th>
<th style="padding:10px 20px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:120px;">Ukupno</th>
</tr>
</thead>
<tbody>
{{range .Stavke}}
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 20px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
<td style="padding:10px 20px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
<td style="padding:10px 20px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .CenaPoKomadu}} din</td>
<td style="padding:10px 20px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr style="border-top:0.5px solid var(--ivica);">
<td colspan="3" style="padding:12px 20px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-sporedni);">Ukupno:</td>
<td style="padding:12px 20px;text-align:right;font-size:16px;font-weight:600;color:var(--sb-akcent);">{{printf "%.2f" .Nalog.Ukupno}} din</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- zona za brisanje -->
<div class="kartica detalji-kartica" style="border-color:#dc262633;">
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
<div style="font-size:14px;font-weight:500;color:#dc2626;margin-bottom:4px;">Brisanje naloga</div>
<div style="font-size:13px;color:var(--tekst-sporedni);">
Brisanje je trajno. Količine artikala biće vraćene na stanje u magacinu.
</div>
</div>
<form method="POST" action="/prodaja/obrisi/{{.Nalog.ID}}">
<button type="submit"
onclick="return confirm('Da li ste sigurni da želite da obrišete nalog {{.Nalog.BrojNaloga}}?\n\nKoličine artikala biće vraćene na stanje.')"
style="padding:9px 20px;background:#dc2626;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;white-space:nowrap;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">
Obriši nalog
</button>
</form>
</div>
</div>
</div>
{{end}}
+238
View File
@@ -0,0 +1,238 @@
{{template "base" .}}
{{define "naslov"}}Nova prodaja — 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; opacity: 0; }
.forma-kartica:nth-child(1) { animation-delay: 0.04s; }
.forma-kartica:nth-child(2) { animation-delay: 0.12s; }
.greska-animacija { animation: shake 0.4s ease; }
@media (max-width: 768px) {
.stavke-tabela-wrapper { display: none; }
.stavke-kartice { display: flex !important; }
}
</style>
{{end}}
{{define "sadrzaj"}}
<!-- lista artikala kao JSON — sadrži id, naziv i prodajnu cenu -->
<script>var _ntechArtikli = {{.ArtikliJSON}};</script>
<div style="width:100%;"
x-data="{
stavke: [{artikal_id: '', kolicina: 1, cena: 0}],
artikliOpcije: _ntechArtikli,
dodajStavku() {
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0});
},
ukloniStavku(i) {
if (this.stavke.length > 1) this.stavke.splice(i, 1);
},
popuniCenu(stavka) {
const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id);
if (a) stavka.cena = a.cena;
},
ukupnoStavke(s) {
return (parseFloat(s.kolicina) * parseFloat(s.cena) || 0).toFixed(2);
},
ukupnoSvega() {
return this.stavke.reduce((z, s) => z + (parseFloat(s.kolicina) * parseFloat(s.cena) || 0), 0).toFixed(2);
}
}">
<!-- nazad dugme -->
<a href="/prodaja"
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 prodaju
</a>
<form method="POST" action="/prodaja/nova">
{{if .Greska}}
<div class="greska-animacija"
style="background:var(--kartica);border:0.5px solid #dc2626;border-radius:8px;padding:10px 14px;margin-bottom:16px;font-size:13px;color:#dc2626;">
{{.Greska}}
</div>
{{end}}
<!-- zaglavlje prodaje -->
<div class="kartica forma-kartica" style="margin-bottom:16px;">
<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Nova prodaja</span>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Klijent</label>
<select name="klijent_id" style="width:100%;">
<option value="">— bez klijenta —</option>
{{range .Klijenti}}
<option value="{{.ID}}">
{{if .NazivFirme}}{{.NazivFirme}}{{else}}{{.Ime}} {{.Prezime}}{{end}}
</option>
{{end}}
</select>
</div>
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Napomena</label>
<textarea name="napomena" rows="2"
placeholder="Interna napomena o prodaji..."
style="width:100%;resize:vertical;"></textarea>
</div>
</div>
</div>
<!-- stavke -->
<div class="kartica forma-kartica" style="margin-bottom:16px;">
<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Stavke</span>
<button type="button" @click="dodajStavku()"
style="padding:6px 14px;background:var(--sb-aktivan);color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
+ Dodaj stavku
</button>
</div>
<!-- desktop tabela stavki -->
<div class="stavke-tabela-wrapper" style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:0.5px solid var(--ivica);">
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Artikal</th>
<th style="padding:8px 10px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:90px;">Količina</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:140px;">Cena/kom (din)</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:110px;">Ukupno</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody>
<template x-for="(stavka, i) in stavke" :key="i">
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 10px;">
<select :name="'artikal_id[]'" x-model="stavka.artikal_id"
@change="popuniCenu(stavka)"
style="width:100%;">
<option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id">
<option :value="a.id" x-text="a.naziv"></option>
</template>
</select>
</td>
<td style="padding:8px 10px;">
<input type="number" :name="'kolicina[]'" x-model="stavka.kolicina"
min="1" style="width:100%;text-align:center;">
</td>
<td style="padding:8px 10px;">
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena"
min="0" step="0.01" style="width:100%;text-align:right;">
</td>
<td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
<span x-text="ukupnoStavke(stavka) + ' din'"></span>
</td>
<td style="padding:8px 10px;text-align:center;">
<button type="button" @click="ukloniStavku(i)"
x-show="stavke.length > 1"
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:18px;line-height:1;padding:2px 6px;border-radius:4px;transition:background 0.15s;"
onmouseover="this.style.background='rgba(220,38,38,0.08)'"
onmouseout="this.style.background='none'"
title="Ukloni stavku">×</button>
</td>
</tr>
</template>
</tbody>
<tfoot>
<tr style="border-top:0.5px solid var(--ivica);">
<td colspan="3" style="padding:10px;text-align:right;font-size:13px;color:var(--tekst-sporedni);font-weight:500;">Ukupno:</td>
<td style="padding:10px;text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);">
<span x-text="ukupnoSvega() + ' din'"></span>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<!-- mobilne kartice stavki -->
<div class="stavke-kartice" style="display:none;flex-direction:column;gap:10px;">
<template x-for="(stavka, i) in stavke" :key="i">
<div style="border:0.5px solid var(--ivica);border-radius:8px;padding:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="font-size:13px;font-weight:500;color:var(--tekst-sporedni);"
x-text="'Stavka ' + (i + 1)"></span>
<button type="button" @click="ukloniStavku(i)"
x-show="stavke.length > 1"
style="background:none;border:0.5px solid #dc2626;color:#dc2626;cursor:pointer;font-size:13px;padding:2px 8px;border-radius:4px;">
Ukloni
</button>
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Artikal</label>
<select :name="'artikal_id[]'" x-model="stavka.artikal_id"
@change="popuniCenu(stavka)"
style="width:100%;">
<option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id">
<option :value="a.id" x-text="a.naziv"></option>
</template>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Količina</label>
<input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" min="1" style="width:100%;">
</div>
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Cena/kom (din)</label>
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" style="width:100%;">
</div>
</div>
<div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
Ukupno: <span x-text="ukupnoStavke(stavka) + ' din'"></span>
</div>
</div>
</div>
</template>
<div style="text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);padding:8px 4px;">
Ukupno: <span x-text="ukupnoSvega() + ' din'"></span>
</div>
</div>
</div>
<!-- dugmad forme -->
<div style="display:flex;justify-content:flex-end;gap:10px;">
<a href="/prodaja"
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'">
Sačuvaj prodaju
</button>
</div>
</form>
</div>
{{end}}