Usluge: zaseban modul (cenovnik usluga)

Usluga je zaseban entitet, ne artikal: svoja tabela usluge (naziv, šifra,
jedinica mere, cena, PDV, opis), repo, handleri i forma. Auto-šifra USL-001…999,
jedinica mere (usluga/sat/kom). Bez nabavne/prodajne cene, dobavljača, lagera i
marže. /usluge lista + CRUD; stari tip-filter handler uklonjen iz magacina.
This commit is contained in:
2026-06-21 23:04:56 +02:00
parent 31d8917adc
commit ef0b917bf6
13 changed files with 637 additions and 6 deletions
+22
View File
@@ -104,6 +104,28 @@ type ArtikalFilter struct {
Offset int
}
// UslugaFilter su parametri za listanje usluga
type UslugaFilter struct {
Pretraga string
Arhivirani bool
Limit int
Offset int
}
// UslugaRepository definiše operacije nad uslugama (zaseban entitet od artikala)
type UslugaRepository interface {
Lista(ctx context.Context, filter UslugaFilter) ([]model.Usluga, error)
PrebrojiPoFilteru(ctx context.Context, filter UslugaFilter) (int, error)
DohvatiID(ctx context.Context, id int64) (*model.Usluga, error)
Kreiraj(ctx context.Context, u *model.Usluga) (int64, error)
Izmeni(ctx context.Context, u *model.Usluga) error
Obrisi(ctx context.Context, id int64) error
// Kategorije vraća postojeće (distinct) kategorije usluga za predlog pri unosu
Kategorije(ctx context.Context) ([]string, error)
// SledecaSifra vraća predlog sledeće auto-šifre usluge (USL-001 … USL-999)
SledecaSifra(ctx context.Context) (string, error)
}
// NabavkaRepository definiše operacije nad nabavkama
type NabavkaRepository interface {
Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error)
+181
View File
@@ -0,0 +1,181 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/db"
"ntech/internal/model"
)
// UslugaRepo je SQLite implementacija db.UslugaRepository
type UslugaRepo struct {
db *sql.DB
}
// NoviUslugaRepo kreira repozitorijum usluga nad datom bazom
func NoviUslugaRepo(baza *sql.DB) *UslugaRepo {
return &UslugaRepo{db: baza}
}
// skenirajUslugu čita jedan red u model.Usluga
func skenirajUslugu(s interface {
Scan(dest ...any) error
}) (model.Usluga, error) {
var u model.Usluga
var sifra sql.NullString
var arhiviran int
if err := s.Scan(&u.ID, &sifra, &u.Naziv, &u.Kategorija, &u.JedinicaMere, &u.Cena, &u.PdvStopa, &u.Opis, &arhiviran, &u.DatumUnosa); err != nil {
return u, err
}
u.Sifra = sifra.String
u.Arhiviran = arhiviran == 1
return u, nil
}
const uslugaKolone = "id, sifra, naziv, kategorija, jedinica_mere, cena, pdv_stopa, opis, arhiviran, datum_unosa"
// Lista vraća usluge prema filteru (pretraga po nazivu/šifri/kategoriji, aktivne/arhivirane)
func (r *UslugaRepo) Lista(ctx context.Context, filter db.UslugaFilter) ([]model.Usluga, error) {
upit := "SELECT " + uslugaKolone + " FROM usluge WHERE 1=1"
var args []any
if filter.Pretraga != "" {
upit += " AND (naziv LIKE ? OR sifra LIKE ? OR kategorija LIKE ?)"
t := "%" + filter.Pretraga + "%"
args = append(args, t, 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: UslugaRepo.Lista: %w", err)
}
defer redovi.Close()
var usluge []model.Usluga
for redovi.Next() {
u, err := skenirajUslugu(redovi)
if err != nil {
return nil, fmt.Errorf("ntech: UslugaRepo.Lista: %w", err)
}
usluge = append(usluge, u)
}
return usluge, redovi.Err()
}
// PrebrojiPoFilteru vraća broj usluga koje zadovoljavaju filter (za paginaciju)
func (r *UslugaRepo) PrebrojiPoFilteru(ctx context.Context, filter db.UslugaFilter) (int, error) {
upit := "SELECT COUNT(*) FROM usluge WHERE 1=1"
var args []any
if filter.Pretraga != "" {
upit += " AND (naziv LIKE ? OR sifra LIKE ? OR kategorija LIKE ?)"
t := "%" + filter.Pretraga + "%"
args = append(args, t, t, t)
}
if filter.Arhivirani {
upit += " AND arhiviran = 1"
} else {
upit += " AND arhiviran = 0"
}
var n int
if err := r.db.QueryRowContext(ctx, upit, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("ntech: UslugaRepo.PrebrojiPoFilteru: %w", err)
}
return n, nil
}
// DohvatiID vraća jednu uslugu po ID-u
func (r *UslugaRepo) DohvatiID(ctx context.Context, id int64) (*model.Usluga, error) {
red := r.db.QueryRowContext(ctx, "SELECT "+uslugaKolone+" FROM usluge WHERE id = ?", id)
u, err := skenirajUslugu(red)
if err != nil {
return nil, fmt.Errorf("ntech: UslugaRepo.DohvatiID: %w", err)
}
return &u, nil
}
// Kreiraj upisuje novu uslugu i vraća njen ID
func (r *UslugaRepo) Kreiraj(ctx context.Context, u *model.Usluga) (int64, error) {
var sifra any
if u.Sifra != "" {
sifra = u.Sifra
}
rez, err := r.db.ExecContext(ctx, `
INSERT INTO usluge (sifra, naziv, kategorija, jedinica_mere, cena, pdv_stopa, opis)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis,
)
if err != nil {
return 0, fmt.Errorf("ntech: UslugaRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: UslugaRepo.Kreiraj: last insert id: %w", err)
}
return id, nil
}
// Izmeni ažurira postojeću uslugu
func (r *UslugaRepo) Izmeni(ctx context.Context, u *model.Usluga) error {
var sifra any
if u.Sifra != "" {
sifra = u.Sifra
}
_, err := r.db.ExecContext(ctx, `
UPDATE usluge SET sifra = ?, naziv = ?, kategorija = ?, jedinica_mere = ?, cena = ?, pdv_stopa = ?, opis = ?
WHERE id = ?`,
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis, u.ID,
)
if err != nil {
return fmt.Errorf("ntech: UslugaRepo.Izmeni: %w", err)
}
return nil
}
// Obrisi trajno briše uslugu po ID-u
func (r *UslugaRepo) Obrisi(ctx context.Context, id int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM usluge WHERE id = ?", id); err != nil {
return fmt.Errorf("ntech: UslugaRepo.Obrisi: %w", err)
}
return nil
}
// Kategorije vraća distinct neprazne kategorije usluga (za predlog pri unosu)
func (r *UslugaRepo) Kategorije(ctx context.Context) ([]string, error) {
redovi, err := r.db.QueryContext(ctx, "SELECT DISTINCT kategorija FROM usluge WHERE kategorija != '' ORDER BY kategorija")
if err != nil {
return nil, fmt.Errorf("ntech: UslugaRepo.Kategorije: %w", err)
}
defer redovi.Close()
var kat []string
for redovi.Next() {
var k string
if err := redovi.Scan(&k); err != nil {
return nil, fmt.Errorf("ntech: UslugaRepo.Kategorije: %w", err)
}
kat = append(kat, k)
}
return kat, redovi.Err()
}
// SledecaSifra vraća predlog sledeće auto-šifre usluge u formatu USL-001 … USL-999
func (r *UslugaRepo) SledecaSifra(ctx context.Context) (string, error) {
var maks sql.NullInt64
err := r.db.QueryRowContext(ctx,
"SELECT MAX(CAST(SUBSTR(sifra, 5) AS INTEGER)) FROM usluge WHERE sifra LIKE 'USL-%'").Scan(&maks)
if err != nil {
return "USL-001", fmt.Errorf("ntech: UslugaRepo.SledecaSifra: %w", err)
}
return fmt.Sprintf("USL-%03d", maks.Int64+1), nil
}
+4 -1
View File
@@ -20,6 +20,7 @@ type Handler struct {
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
UslugeRepo db.UslugaRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
@@ -41,7 +42,7 @@ type Handler struct {
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
Verzija string
JelDemo bool
JelDemo bool
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
TemplatesFS fs.FS
@@ -84,6 +85,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
DB: baza,
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
UslugeRepo: sqlite.NoviUslugaRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
@@ -113,6 +115,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.DB = novaDB
h.Artikli = sqlite.NoviArtikalRepo(novaDB)
h.UslugeRepo = sqlite.NoviUslugaRepo(novaDB)
h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB)
h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB)
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
+1
View File
@@ -37,6 +37,7 @@ var saSidebar = []string{
"profil_tema",
"prodaja", "prodaja_detalji", "prodaja_forma",
"servis", "servis_arhiva", "servis_forma", "servis_detalji",
"usluge", "usluge_forma",
}
// standalone su šabloni bez base layouta
-5
View File
@@ -42,11 +42,6 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
h.magacinPrikaz(w, r, model.TipProizvod, "magacin", "Magacin", "/magacin")
}
// Usluge renderuje listu usluga (tip 'usluga')
func (h *Handler) Usluge(w http.ResponseWriter, r *http.Request) {
h.magacinPrikaz(w, r, model.TipUsluga, "usluge", "Usluge", "/usluge")
}
// 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")
+199
View File
@@ -0,0 +1,199 @@
package handler
import (
"net/http"
"strconv"
"strings"
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// PodaciUsluga su podaci za listu usluga
type PodaciUsluga struct {
model.PodaciStranice
Usluge []model.Usluga
Pretraga string
Sacuvano bool
Obrisan bool
}
// PodaciFormeUsluge su podaci za formu nove/izmenjene usluge
type PodaciFormeUsluge struct {
model.PodaciStranice
Usluga model.Usluga
Kategorije []string // postojeće kategorije za predlog (datalist)
Greska string
Izmena bool
}
// Usluge renderuje listu usluga (cenovnik usluga)
func (h *Handler) Usluge(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")
usluge, err := h.UslugeRepo.Lista(r.Context(), db.UslugaFilter{Pretraga: pretraga})
if err != nil {
http.Error(w, "Greška pri učitavanju usluga", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "usluge"
ps.NaslovStranice = "Usluge"
h.renderujTemplate(w, "usluge", PodaciUsluga{
PodaciStranice: ps,
Usluge: usluge,
Pretraga: pretraga,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1",
})
}
// NovaUsluga prikazuje praznu formu sa predloženom auto-šifrom (USL-NNN)
func (h *Handler) NovaUsluga(w http.ResponseWriter, r *http.Request) {
sifra, err := h.UslugeRepo.SledecaSifra(r.Context())
if err != nil {
sifra = "USL-001"
}
h.renderujFormuUsluge(w, r, model.Usluga{Sifra: sifra, PdvStopa: 20, JedinicaMere: "usluga"}, false, "")
}
// IzmeniUslugu prikazuje formu sa postojećom uslugom
func (h *Handler) IzmeniUslugu(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "Neispravan ID usluge", http.StatusBadRequest)
return
}
usluga, err := h.UslugeRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "Usluga nije pronađena", http.StatusNotFound)
return
}
h.renderujFormuUsluge(w, r, *usluga, true, "")
}
// SacuvajUslugu prima POST i kreira novu uslugu
func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.dodaj"); !ok {
return
}
usluga, greska := parseFormuUsluge(r)
if greska != "" {
h.renderujFormuUsluge(w, r, usluga, false, greska)
return
}
if _, err := h.UslugeRepo.Kreiraj(r.Context(), &usluga); err != nil {
h.renderujFormuUsluge(w, r, usluga, false, "Greška pri čuvanju usluge. Pokušajte ponovo.")
return
}
http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther)
}
// SacuvajIzmenuUsluge prima POST i ažurira postojeću uslugu
func (h *Handler) SacuvajIzmenuUsluge(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 usluge", http.StatusBadRequest)
return
}
usluga, greska := parseFormuUsluge(r)
usluga.ID = id
if greska != "" {
h.renderujFormuUsluge(w, r, usluga, true, greska)
return
}
if err := h.UslugeRepo.Izmeni(r.Context(), &usluga); err != nil {
h.renderujFormuUsluge(w, r, usluga, true, "Greška pri čuvanju usluge. Pokušajte ponovo.")
return
}
http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther)
}
// ObrisiUslugu briše uslugu po ID-u
func (h *Handler) ObrisiUslugu(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 usluge", http.StatusBadRequest)
return
}
if err := h.UslugeRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju usluge", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/usluge?obrisan=1", http.StatusSeeOther)
}
// parseFormuUsluge čita i validira polja forme usluge. Vraća model i poruku o grešci.
func parseFormuUsluge(r *http.Request) (model.Usluga, string) {
if err := r.ParseForm(); err != nil {
return model.Usluga{}, "Greška pri čitanju forme."
}
naziv := strings.TrimSpace(r.FormValue("naziv"))
if naziv == "" {
return model.Usluga{Kategorija: strings.TrimSpace(r.FormValue("kategorija"))}, "Naziv usluge je obavezan."
}
cena, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("cena")), 64)
if err != nil || cena < 0 {
cena = 0
}
pdv, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("pdv_stopa")), 64)
if err != nil || pdv < 0 {
pdv = 20
}
jm := strings.TrimSpace(r.FormValue("jedinica_mere"))
if jm == "" {
jm = "usluga"
}
return model.Usluga{
Sifra: strings.TrimSpace(r.FormValue("sifra")),
Naziv: naziv,
Kategorija: strings.TrimSpace(r.FormValue("kategorija")),
JedinicaMere: jm,
Cena: cena,
PdvStopa: pdv,
Opis: strings.TrimSpace(r.FormValue("opis")),
}, ""
}
// renderujFormuUsluge prikazuje formu usluge (zajedničko za novu i izmenu)
func (h *Handler) renderujFormuUsluge(w http.ResponseWriter, r *http.Request, usluga model.Usluga, 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
}
kategorije, _ := h.UslugeRepo.Kategorije(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "usluge"
if izmena {
ps.NaslovStranice = "Izmena usluge"
} else {
ps.NaslovStranice = "Nova usluga"
}
h.renderujTemplate(w, "usluge_forma", PodaciFormeUsluge{
PodaciStranice: ps,
Usluga: usluga,
Kategorije: kategorije,
Greska: greska,
Izmena: izmena,
})
}
+17
View File
@@ -0,0 +1,17 @@
package model
// Usluga je stavka cenovnika usluga (npr. čišćenje laptopa, zamena ventilatora).
// Za razliku od artikla, usluga ne prati lager i nema nabavnu cenu ni dobavljače —
// ima samo cenu usluge i PDV stopu. Kategorija je tekstualna oznaka.
type Usluga struct {
ID int64
Sifra string
Naziv string
Kategorija string
JedinicaMere string
Cena float64
PdvStopa float64
Opis string
Arhiviran bool
DatumUnosa string
}