Dodavanje modula prodaje — lista, forma, detalji, provera stanja, vraćanje pri brisanju
This commit is contained in:
@@ -90,6 +90,11 @@ func main() {
|
||||
r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
|
||||
r.Post("/servis/obrisi/{id}", h.ObrisiNalog)
|
||||
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)
|
||||
err = http.ListenAndServe(":"+port, r)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -64,3 +64,13 @@ type ServisRepository interface {
|
||||
Obrisi(ctx context.Context, id int64) 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Handler struct {
|
||||
NabavkeRepo db.NabavkaRepository
|
||||
KlijentiRepo db.KlijentRepository
|
||||
ServisRepo db.ServisRepository
|
||||
ProdajaRepo db.ProdajaRepository
|
||||
}
|
||||
|
||||
// Novi kreira novi Handler sa datom bazom
|
||||
@@ -28,5 +29,6 @@ func Novi(baza *sql.DB) *Handler {
|
||||
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
|
||||
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
|
||||
ServisRepo: sqlite.NoviServisRepo(baza),
|
||||
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user