diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index f9d2e08..5272345 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -308,6 +308,11 @@ func main() { r.With(doz("artikal.izmeni")).Post("/usluge/izmeni/{id}", h.SacuvajIzmenuUsluge) r.With(doz("artikal.obrisi")).Post("/usluge/obrisi/{id}", h.ObrisiUslugu) r.Get("/troskovi", h.Troskovi) + r.Get("/troskovi/novi", h.NoviTrosak) + r.With(doz("artikal.dodaj")).Post("/troskovi/novi", h.SacuvajTrosak) + r.Get("/troskovi/izmeni/{id}", h.IzmeniTrosak) + r.With(doz("artikal.izmeni")).Post("/troskovi/izmeni/{id}", h.SacuvajIzmenuTroska) + r.With(doz("artikal.obrisi")).Post("/troskovi/obrisi/{id}", h.ObrisiTrosak) r.Get("/magacin/kartica/{id}", h.MagacinskaKartica) r.Get("/magacin/novi", h.NoviArtikal) r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal) diff --git a/internal/db/repository.go b/internal/db/repository.go index 9ffe0f0..dfb77e4 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -126,6 +126,25 @@ type UslugaRepository interface { SledecaSifra(ctx context.Context) (string, error) } +// TrosakFilter su parametri za listanje troškova +type TrosakFilter struct { + Pretraga string + Arhivirani bool + Limit int + Offset int +} + +// TrosakRepository definiše operacije nad šifrarnikom vrsta troškova +type TrosakRepository interface { + Lista(ctx context.Context, filter TrosakFilter) ([]model.Trosak, error) + DohvatiID(ctx context.Context, id int64) (*model.Trosak, error) + Kreiraj(ctx context.Context, t *model.Trosak) (int64, error) + Izmeni(ctx context.Context, t *model.Trosak) error + Obrisi(ctx context.Context, id int64) error + // SledecaSifra vraća predlog sledeće auto-šifre troška (TRO-001 … TRO-999) + SledecaSifra(ctx context.Context) (string, error) +} + // NabavkaRepository definiše operacije nad nabavkama type NabavkaRepository interface { Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error) diff --git a/internal/db/sqlite/trosak.go b/internal/db/sqlite/trosak.go new file mode 100644 index 0000000..cbed94f --- /dev/null +++ b/internal/db/sqlite/trosak.go @@ -0,0 +1,142 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + + "ntech/internal/db" + "ntech/internal/model" +) + +// TrosakRepo je SQLite implementacija db.TrosakRepository +type TrosakRepo struct { + db *sql.DB +} + +// NoviTrosakRepo kreira repozitorijum troškova nad datom bazom +func NoviTrosakRepo(baza *sql.DB) *TrosakRepo { + return &TrosakRepo{db: baza} +} + +// skenirajTrosak čita jedan red u model.Trosak +func skenirajTrosak(s interface { + Scan(dest ...any) error +}) (model.Trosak, error) { + var t model.Trosak + var sifra sql.NullString + var arhiviran int + if err := s.Scan(&t.ID, &sifra, &t.Naziv, &t.Cena, &t.Opis, &arhiviran, &t.DatumUnosa); err != nil { + return t, err + } + t.Sifra = sifra.String + t.Arhiviran = arhiviran == 1 + return t, nil +} + +const trosakKolone = "id, sifra, naziv, cena, opis, arhiviran, datum_unosa" + +// Lista vraća troškove prema filteru (pretraga po nazivu/šifri, aktivne/arhivirane) +func (r *TrosakRepo) Lista(ctx context.Context, filter db.TrosakFilter) ([]model.Trosak, error) { + upit := "SELECT " + trosakKolone + " FROM troskovi WHERE 1=1" + var args []any + + if filter.Pretraga != "" { + upit += " AND (naziv LIKE ? OR sifra LIKE ?)" + t := "%" + filter.Pretraga + "%" + args = append(args, t, t) + } + if filter.Arhivirani { + upit += " AND arhiviran = 1" + } else { + upit += " AND arhiviran = 0" + } + upit += " ORDER BY naziv" + if filter.Limit > 0 { + upit += " LIMIT ? OFFSET ?" + args = append(args, filter.Limit, filter.Offset) + } + + redovi, err := r.db.QueryContext(ctx, upit, args...) + if err != nil { + return nil, fmt.Errorf("ntech: TrosakRepo.Lista: %w", err) + } + defer redovi.Close() + + var troskovi []model.Trosak + for redovi.Next() { + t, err := skenirajTrosak(redovi) + if err != nil { + return nil, fmt.Errorf("ntech: TrosakRepo.Lista: %w", err) + } + troskovi = append(troskovi, t) + } + return troskovi, redovi.Err() +} + +// DohvatiID vraća jedan trošak po ID-u +func (r *TrosakRepo) DohvatiID(ctx context.Context, id int64) (*model.Trosak, error) { + red := r.db.QueryRowContext(ctx, "SELECT "+trosakKolone+" FROM troskovi WHERE id = ?", id) + t, err := skenirajTrosak(red) + if err != nil { + return nil, fmt.Errorf("ntech: TrosakRepo.DohvatiID: %w", err) + } + return &t, nil +} + +// Kreiraj upisuje nov trošak i vraća njegov ID +func (r *TrosakRepo) Kreiraj(ctx context.Context, t *model.Trosak) (int64, error) { + var sifra any + if t.Sifra != "" { + sifra = t.Sifra + } + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO troskovi (sifra, naziv, cena, opis) + VALUES (?, ?, ?, ?)`, + sifra, t.Naziv, t.Cena, t.Opis, + ) + if err != nil { + return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: last insert id: %w", err) + } + return id, nil +} + +// Izmeni ažurira postojeći trošak +func (r *TrosakRepo) Izmeni(ctx context.Context, t *model.Trosak) error { + var sifra any + if t.Sifra != "" { + sifra = t.Sifra + } + _, err := r.db.ExecContext(ctx, ` + UPDATE troskovi SET sifra = ?, naziv = ?, cena = ?, opis = ? + WHERE id = ?`, + sifra, t.Naziv, t.Cena, t.Opis, t.ID, + ) + if err != nil { + return fmt.Errorf("ntech: TrosakRepo.Izmeni: %w", err) + } + return nil +} + +// Obrisi trajno briše trošak po ID-u +func (r *TrosakRepo) Obrisi(ctx context.Context, id int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM troskovi WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: TrosakRepo.Obrisi: %w", err) + } + return nil +} + +// SledecaSifra vraća predlog sledeće auto-šifre troška u formatu TRO-001 … TRO-999 +func (r *TrosakRepo) SledecaSifra(ctx context.Context) (string, error) { + var maks sql.NullInt64 + err := r.db.QueryRowContext(ctx, + "SELECT MAX(CAST(SUBSTR(sifra, 5) AS INTEGER)) FROM troskovi WHERE sifra LIKE 'TRO-%'").Scan(&maks) + if err != nil { + return "TRO-001", fmt.Errorf("ntech: TrosakRepo.SledecaSifra: %w", err) + } + return fmt.Sprintf("TRO-%03d", maks.Int64+1), nil +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 75f50c4..e6b76db 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -21,6 +21,7 @@ type Handler struct { PutanjaBaze string Artikli db.ArtikalRepository UslugeRepo db.UslugaRepository + TroskoviRepo db.TrosakRepository KategorijeRepo db.KategorijaRepository DobavljaciRepo db.DobavljacRepository NabavkeRepo db.NabavkaRepository @@ -86,6 +87,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler { totpKljuc: totpKljuc, Artikli: sqlite.NoviArtikalRepo(baza), UslugeRepo: sqlite.NoviUslugaRepo(baza), + TroskoviRepo: sqlite.NoviTrosakRepo(baza), KategorijeRepo: sqlite.NovaKategorijaRepo(baza), DobavljaciRepo: sqlite.NoviDobavljacRepo(baza), NabavkeRepo: sqlite.NoviNabavkaRepo(baza), @@ -116,6 +118,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.DB = novaDB h.Artikli = sqlite.NoviArtikalRepo(novaDB) h.UslugeRepo = sqlite.NoviUslugaRepo(novaDB) + h.TroskoviRepo = sqlite.NoviTrosakRepo(novaDB) h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB) h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB) h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB) diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 420cf16..bbc1090 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -38,6 +38,7 @@ var saSidebar = []string{ "prodaja", "prodaja_detalji", "prodaja_forma", "servis", "servis_arhiva", "servis_forma", "servis_detalji", "usluge", "usluge_forma", + "troskovi", "troskovi_forma", } // standalone su šabloni bez base layouta diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index f766582..d11f62a 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -42,11 +42,6 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { h.magacinPrikaz(w, r, model.TipProizvod, "magacin", "Magacin", "/magacin") } -// Troskovi renderuje listu troškova (tip 'trosak') -func (h *Handler) Troskovi(w http.ResponseWriter, r *http.Request) { - h.magacinPrikaz(w, r, model.TipTrosak, "troskovi", "Troškovi", "/troskovi") -} - // magacinPrikaz je zajednička logika liste artikala, parametrizovana tipom; isti // šablon služi za proizvode, usluge i troškove (razlikuju se samo tip i bazna ruta) func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, stranica, naslov, osnovniUrl string) { diff --git a/internal/handler/troskovi.go b/internal/handler/troskovi.go new file mode 100644 index 0000000..fa43168 --- /dev/null +++ b/internal/handler/troskovi.go @@ -0,0 +1,181 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "ntech/internal/db" + "ntech/internal/db/sqlite" + "ntech/internal/model" + + "github.com/go-chi/chi/v5" +) + +// PodaciTroskovi su podaci za listu troškova +type PodaciTroskovi struct { + model.PodaciStranice + Troskovi []model.Trosak + Pretraga string + Sacuvano bool + Obrisan bool +} + +// PodaciFormeTroska su podaci za formu novog/izmenjenog troška +type PodaciFormeTroska struct { + model.PodaciStranice + Trosak model.Trosak + Greska string + Izmena bool +} + +// Troskovi renderuje listu šifrarnika vrsta troškova +func (h *Handler) Troskovi(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 := r.URL.Query().Get("pretraga") + troskovi, err := h.TroskoviRepo.Lista(r.Context(), db.TrosakFilter{Pretraga: pretraga}) + if err != nil { + http.Error(w, "Greška pri učitavanju troškova", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "troskovi" + ps.NaslovStranice = "Troškovi" + h.renderujTemplate(w, "troskovi", PodaciTroskovi{ + PodaciStranice: ps, + Troskovi: troskovi, + Pretraga: pretraga, + Sacuvano: r.URL.Query().Get("sacuvano") == "1", + Obrisan: r.URL.Query().Get("obrisan") == "1", + }) +} + +// NoviTrosak prikazuje praznu formu sa predloženom auto-šifrom (TRO-NNN) +func (h *Handler) NoviTrosak(w http.ResponseWriter, r *http.Request) { + sifra, err := h.TroskoviRepo.SledecaSifra(r.Context()) + if err != nil { + sifra = "TRO-001" + } + h.renderujFormuTroska(w, r, model.Trosak{Sifra: sifra}, false, "") +} + +// IzmeniTrosak prikazuje formu sa postojećim troškom +func (h *Handler) IzmeniTrosak(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Neispravan ID troška", http.StatusBadRequest) + return + } + trosak, err := h.TroskoviRepo.DohvatiID(r.Context(), id) + if err != nil { + http.Error(w, "Trošak nije pronađen", http.StatusNotFound) + return + } + h.renderujFormuTroska(w, r, *trosak, true, "") +} + +// SacuvajTrosak prima POST i kreira nov trošak +func (h *Handler) SacuvajTrosak(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "artikal.dodaj"); !ok { + return + } + trosak, greska := parseFormuTroska(r) + if greska != "" { + h.renderujFormuTroska(w, r, trosak, false, greska) + return + } + if _, err := h.TroskoviRepo.Kreiraj(r.Context(), &trosak); err != nil { + h.renderujFormuTroska(w, r, trosak, false, "Greška pri čuvanju troška. Pokušajte ponovo.") + return + } + http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther) +} + +// SacuvajIzmenuTroska prima POST i ažurira postojeći trošak +func (h *Handler) SacuvajIzmenuTroska(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Neispravan ID troška", http.StatusBadRequest) + return + } + trosak, greska := parseFormuTroska(r) + trosak.ID = id + if greska != "" { + h.renderujFormuTroska(w, r, trosak, true, greska) + return + } + if err := h.TroskoviRepo.Izmeni(r.Context(), &trosak); err != nil { + h.renderujFormuTroska(w, r, trosak, true, "Greška pri čuvanju troška. Pokušajte ponovo.") + return + } + http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther) +} + +// ObrisiTrosak briše trošak po ID-u +func (h *Handler) ObrisiTrosak(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "artikal.obrisi"); !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Neispravan ID troška", http.StatusBadRequest) + return + } + if err := h.TroskoviRepo.Obrisi(r.Context(), id); err != nil { + http.Error(w, "Greška pri brisanju troška", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/troskovi?obrisan=1", http.StatusSeeOther) +} + +// parseFormuTroska čita i validira polja forme troška +func parseFormuTroska(r *http.Request) (model.Trosak, string) { + if err := r.ParseForm(); err != nil { + return model.Trosak{}, "Greška pri čitanju forme." + } + naziv := strings.TrimSpace(r.FormValue("naziv")) + if naziv == "" { + return model.Trosak{}, "Naziv troška je obavezan." + } + cena, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("cena")), 64) + if err != nil || cena < 0 { + cena = 0 + } + return model.Trosak{ + Sifra: strings.TrimSpace(r.FormValue("sifra")), + Naziv: naziv, + Cena: cena, + Opis: strings.TrimSpace(r.FormValue("opis")), + }, "" +} + +// renderujFormuTroska prikazuje formu troška (zajedničko za nov i izmenu) +func (h *Handler) renderujFormuTroska(w http.ResponseWriter, r *http.Request, trosak model.Trosak, izmena bool, greska string) { + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + if err != nil { + http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) + return + } + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "troskovi" + if izmena { + ps.NaslovStranice = "Izmena troška" + } else { + ps.NaslovStranice = "Nov trošak" + } + h.renderujTemplate(w, "troskovi_forma", PodaciFormeTroska{ + PodaciStranice: ps, + Trosak: trosak, + Greska: greska, + Izmena: izmena, + }) +} diff --git a/internal/model/trosak.go b/internal/model/trosak.go new file mode 100644 index 0000000..9dd6d25 --- /dev/null +++ b/internal/model/trosak.go @@ -0,0 +1,14 @@ +package model + +// Trosak je stavka šifrarnika vrsta troškova (npr. Prevoz, Carina, Ambalaža). +// To je katalog dodatnih troškova koji se vezuju za radni nalog i ulaze u cenu +// koštanja. NIJE evidencija rashoda firme (struja, kirija) — to je poseban modul. +type Trosak struct { + ID int64 + Sifra string + Naziv string + Cena float64 + Opis string + Arhiviran bool + DatumUnosa string +} diff --git a/migrations/065_troskovi.sql b/migrations/065_troskovi.sql new file mode 100644 index 0000000..de50833 --- /dev/null +++ b/migrations/065_troskovi.sql @@ -0,0 +1,14 @@ +-- Šifrarnik vrsta troškova (Opcija 1): katalog dodatnih troškova koji se vezuju +-- za radni nalog (npr. Prevoz, Carina, Ambalaža). Ulaze u cenu koštanja naloga, +-- ne na fakturu klijentu. Ovo NIJE knjiga rashoda firme (struja/kirija) — to je +-- poseban modul. Trošak ima samo podrazumevanu cenu; iznos se zadaje pri vezivanju. +CREATE TABLE IF NOT EXISTS troskovi ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sifra TEXT, + naziv TEXT NOT NULL, + cena REAL NOT NULL DEFAULT 0, + opis TEXT NOT NULL DEFAULT '', + arhiviran INTEGER NOT NULL DEFAULT 0, + datum_unosa TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_troskovi_arhiviran ON troskovi(arhiviran); diff --git a/web/static/css/main.css b/web/static/css/main.css index 42fa97e..400151c 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -1224,6 +1224,7 @@ select { .magacin-kartice { display: none; flex-direction: column; gap: 12px; } .servis-kartice { display: none; flex-direction: column; gap: 12px; } .usluge-kartice { display: none; flex-direction: column; gap: 12px; } +.troskovi-kartice { display: none; flex-direction: column; gap: 12px; } .prodaja-kartice { display: none; flex-direction: column; gap: 12px; } .pod-kartice { display: none; flex-direction: column; gap: 12px; } .stavke-kartice { display: none; flex-direction: column; gap: 10px; } @@ -1278,6 +1279,7 @@ select { .magacin-tabela { display: none; } .magacin-kartice { display: flex; } .servis-tabela { display: none; } .servis-kartice { display: flex; } .usluge-tabela { display: none; } .usluge-kartice { display: flex; } + .troskovi-tabela { display: none; } .troskovi-kartice { display: flex; } .prodaja-tabela { display: none; } .prodaja-kartice { display: flex; } .pod-tabela { display: none; } .pod-kartice { display: flex; } .stavke-tabela-wrapper { display: none; } .stavke-kartice { display: flex; } diff --git a/web/templates/stranice/troskovi.html b/web/templates/stranice/troskovi.html new file mode 100644 index 0000000..c73d373 --- /dev/null +++ b/web/templates/stranice/troskovi.html @@ -0,0 +1,110 @@ +{{template "base" .}} + +{{define "naslov"}}Troškovi — NTech{{end}} + +{{define "sadrzaj"}} +