diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index acc3c7c..4151d59 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -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) diff --git a/internal/db/errors.go b/internal/db/errors.go new file mode 100644 index 0000000..c29c195 --- /dev/null +++ b/internal/db/errors.go @@ -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 +} diff --git a/internal/db/repository.go b/internal/db/repository.go index f2a271e..26b2217 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -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) +} diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go new file mode 100644 index 0000000..5bff0a7 --- /dev/null +++ b/internal/db/sqlite/prodaja.go @@ -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 +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 4b91ac8..fcc967e 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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), } } diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go new file mode 100644 index 0000000..671f52d --- /dev/null +++ b/internal/handler/prodaja.go @@ -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) + } +} diff --git a/internal/model/prodaja.go b/internal/model/prodaja.go new file mode 100644 index 0000000..cf25a08 --- /dev/null +++ b/internal/model/prodaja.go @@ -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 +} diff --git a/web/templates/stranice/prodaja.html b/web/templates/stranice/prodaja.html new file mode 100644 index 0000000..a920057 --- /dev/null +++ b/web/templates/stranice/prodaja.html @@ -0,0 +1,184 @@ +{{template "base" .}} + +{{define "naslov"}}Prodaja — NTech{{end}} + +{{define "dodatni-css"}} + +{{end}} + +{{define "sadrzaj"}} +
+ + {{if .Sacuvano}} +
Prodaja je uspešno sačuvana.
+ {{end}} + {{if .Obrisan}} +
Prodajni nalog je uspešno obrisan.
+ {{end}} + + +
+
+ + + {{if .Pretraga}} + + ✕ Resetuj + + {{end}} +
+ + + Nova prodaja + +
+ + +
+
+ + + + + + + + + + + + {{range .Nalozi}} + + + + + + + + {{else}} + + + + {{end}} + +
Broj nalogaDatumKlijentUkupnoAkcije
+ {{.BrojNaloga}} + + {{.Datum.Format "02.01.2006."}} + + {{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}} + + {{printf "%.2f" .Ukupno}} din + + + Detalji + +
+ {{if $.Pretraga}} + Nema naloga koji odgovaraju pretrazi. + {{else}} + Nema prodajnih naloga. Dodaj prvu prodaju. + {{end}} +
+
+
+ + +
+ {{range .Nalozi}} +
+
+
+
{{.BrojNaloga}}
+
+ {{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}Bez klijenta{{end}} +
+
+ {{.Datum.Format "02.01.2006."}} +
+
+
+ {{printf "%.2f" .Ukupno}} din +
+
+ + Detalji + +
+ {{else}} +
+ {{if $.Pretraga}} + Nema naloga koji odgovaraju pretrazi. + {{else}} + Nema prodajnih naloga. Dodaj prvu prodaju. + {{end}} +
+ {{end}} +
+ +
+{{end}} diff --git a/web/templates/stranice/prodaja_detalji.html b/web/templates/stranice/prodaja_detalji.html new file mode 100644 index 0000000..1ba0390 --- /dev/null +++ b/web/templates/stranice/prodaja_detalji.html @@ -0,0 +1,128 @@ +{{template "base" .}} + +{{define "naslov"}}Detalji prodaje — NTech{{end}} + +{{define "dodatni-css"}} + +{{end}} + +{{define "sadrzaj"}} +
+ + {{if .Sacuvano}} +
Prodaja je uspešno sačuvana.
+ {{end}} + + + + + Nazad na prodaju + + + +
+
+ + {{.Nalog.BrojNaloga}} + +
+
+
+
Datum prodaje
+
{{.Nalog.Datum.Format "02.01.2006. u 15:04"}}
+
+
+
Klijent
+
+ {{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}} +
+
+
+
Ukupno
+
{{printf "%.2f" .Nalog.Ukupno}} din
+
+ {{if .Nalog.Napomena}} +
+
Napomena
+
{{.Nalog.Napomena}}
+
+ {{end}} +
+
+ + +
+
+ Stavke +
+
+ + + + + + + + + + + {{range .Stavke}} + + + + + + + {{end}} + + + + + + + +
ArtikalKoličinaCena/komUkupno
{{.ArtikalNaziv}}{{.Kolicina}}{{printf "%.2f" .CenaPoKomadu}} din{{printf "%.2f" .Ukupno}} din
Ukupno:{{printf "%.2f" .Nalog.Ukupno}} din
+
+
+ + +
+
+
+
Brisanje naloga
+
+ Brisanje je trajno. Količine artikala biće vraćene na stanje u magacinu. +
+
+
+ +
+
+
+ +
+{{end}} diff --git a/web/templates/stranice/prodaja_forma.html b/web/templates/stranice/prodaja_forma.html new file mode 100644 index 0000000..d835a31 --- /dev/null +++ b/web/templates/stranice/prodaja_forma.html @@ -0,0 +1,238 @@ +{{template "base" .}} + +{{define "naslov"}}Nova prodaja — NTech{{end}} + +{{define "dodatni-css"}} + +{{end}} + +{{define "sadrzaj"}} + + + + +
+ + + + + Nazad na prodaju + + +
+ + {{if .Greska}} +
+ {{.Greska}} +
+ {{end}} + + +
+
+ Nova prodaja +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ Stavke + +
+ + +
+ + + + + + + + + + + + + + + + + + + + +
ArtikalKoličinaCena/kom (din)Ukupno
Ukupno: + +
+
+ + + +
+ + +
+ + Odustani + + +
+ +
+ +
+{{end}}