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:
@@ -302,6 +302,11 @@ func main() {
|
|||||||
r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica)
|
r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica)
|
||||||
r.Get("/magacin", h.Magacin)
|
r.Get("/magacin", h.Magacin)
|
||||||
r.Get("/usluge", h.Usluge)
|
r.Get("/usluge", h.Usluge)
|
||||||
|
r.Get("/usluge/nova", h.NovaUsluga)
|
||||||
|
r.With(doz("artikal.dodaj")).Post("/usluge/nova", h.SacuvajUslugu)
|
||||||
|
r.Get("/usluge/izmeni/{id}", h.IzmeniUslugu)
|
||||||
|
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", h.Troskovi)
|
||||||
r.Get("/magacin/kartica/{id}", h.MagacinskaKartica)
|
r.Get("/magacin/kartica/{id}", h.MagacinskaKartica)
|
||||||
r.Get("/magacin/novi", h.NoviArtikal)
|
r.Get("/magacin/novi", h.NoviArtikal)
|
||||||
|
|||||||
@@ -104,6 +104,28 @@ type ArtikalFilter struct {
|
|||||||
Offset int
|
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
|
// NabavkaRepository definiše operacije nad nabavkama
|
||||||
type NabavkaRepository interface {
|
type NabavkaRepository interface {
|
||||||
Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error)
|
Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type Handler struct {
|
|||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
PutanjaBaze string
|
PutanjaBaze string
|
||||||
Artikli db.ArtikalRepository
|
Artikli db.ArtikalRepository
|
||||||
|
UslugeRepo db.UslugaRepository
|
||||||
KategorijeRepo db.KategorijaRepository
|
KategorijeRepo db.KategorijaRepository
|
||||||
DobavljaciRepo db.DobavljacRepository
|
DobavljaciRepo db.DobavljacRepository
|
||||||
NabavkeRepo db.NabavkaRepository
|
NabavkeRepo db.NabavkaRepository
|
||||||
@@ -41,7 +42,7 @@ type Handler struct {
|
|||||||
PdvKprRepo db.PdvKprRepository
|
PdvKprRepo db.PdvKprRepository
|
||||||
NivelacijaRepo db.NivelacijaRepository
|
NivelacijaRepo db.NivelacijaRepository
|
||||||
Verzija string
|
Verzija string
|
||||||
JelDemo bool
|
JelDemo bool
|
||||||
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
||||||
Templates map[string]*template.Template
|
Templates map[string]*template.Template
|
||||||
TemplatesFS fs.FS
|
TemplatesFS fs.FS
|
||||||
@@ -84,6 +85,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
|
|||||||
DB: baza,
|
DB: baza,
|
||||||
totpKljuc: totpKljuc,
|
totpKljuc: totpKljuc,
|
||||||
Artikli: sqlite.NoviArtikalRepo(baza),
|
Artikli: sqlite.NoviArtikalRepo(baza),
|
||||||
|
UslugeRepo: sqlite.NoviUslugaRepo(baza),
|
||||||
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
|
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
|
||||||
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
|
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
|
||||||
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
|
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
|
||||||
@@ -113,6 +115,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
|
|||||||
func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
|
func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
|
||||||
h.DB = novaDB
|
h.DB = novaDB
|
||||||
h.Artikli = sqlite.NoviArtikalRepo(novaDB)
|
h.Artikli = sqlite.NoviArtikalRepo(novaDB)
|
||||||
|
h.UslugeRepo = sqlite.NoviUslugaRepo(novaDB)
|
||||||
h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB)
|
h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB)
|
||||||
h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB)
|
h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB)
|
||||||
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
|
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ var saSidebar = []string{
|
|||||||
"profil_tema",
|
"profil_tema",
|
||||||
"prodaja", "prodaja_detalji", "prodaja_forma",
|
"prodaja", "prodaja_detalji", "prodaja_forma",
|
||||||
"servis", "servis_arhiva", "servis_forma", "servis_detalji",
|
"servis", "servis_arhiva", "servis_forma", "servis_detalji",
|
||||||
|
"usluge", "usluge_forma",
|
||||||
}
|
}
|
||||||
|
|
||||||
// standalone su šabloni bez base layouta
|
// standalone su šabloni bez base layouta
|
||||||
|
|||||||
@@ -42,11 +42,6 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.magacinPrikaz(w, r, model.TipProizvod, "magacin", "Magacin", "/magacin")
|
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')
|
// Troskovi renderuje listu troškova (tip 'trosak')
|
||||||
func (h *Handler) Troskovi(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Troskovi(w http.ResponseWriter, r *http.Request) {
|
||||||
h.magacinPrikaz(w, r, model.TipTrosak, "troskovi", "Troškovi", "/troskovi")
|
h.magacinPrikaz(w, r, model.TipTrosak, "troskovi", "Troškovi", "/troskovi")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Usluge: zaseban entitet (NIJE artikal) — ne prate lager, nemaju nabavnu cenu
|
||||||
|
-- ni dobavljače. Imaju cenu usluge + PDV stopu. Kategorija je tekstualna oznaka
|
||||||
|
-- (npr. „Servis", „Instalacija"); slobodan unos uz predlog postojećih vrednosti.
|
||||||
|
CREATE TABLE IF NOT EXISTS usluge (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sifra TEXT,
|
||||||
|
naziv TEXT NOT NULL,
|
||||||
|
kategorija TEXT NOT NULL DEFAULT '',
|
||||||
|
cena REAL NOT NULL DEFAULT 0,
|
||||||
|
pdv_stopa REAL NOT NULL DEFAULT 20,
|
||||||
|
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_usluge_arhiviran ON usluge(arhiviran);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Jedinica mere usluge: 'usluga' (fiksna), 'sat' (naplata po satu) ili 'kom'.
|
||||||
|
ALTER TABLE usluge ADD COLUMN jedinica_mere TEXT NOT NULL DEFAULT 'usluga';
|
||||||
@@ -1223,6 +1223,7 @@ select {
|
|||||||
.klijenti-kartice { display: none; flex-direction: column; gap: 12px; }
|
.klijenti-kartice { display: none; flex-direction: column; gap: 12px; }
|
||||||
.magacin-kartice { display: none; flex-direction: column; gap: 12px; }
|
.magacin-kartice { display: none; flex-direction: column; gap: 12px; }
|
||||||
.servis-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; }
|
||||||
.prodaja-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; }
|
.pod-kartice { display: none; flex-direction: column; gap: 12px; }
|
||||||
.stavke-kartice { display: none; flex-direction: column; gap: 10px; }
|
.stavke-kartice { display: none; flex-direction: column; gap: 10px; }
|
||||||
@@ -1276,6 +1277,7 @@ select {
|
|||||||
.klijenti-tabela { display: none; } .klijenti-kartice { display: flex; }
|
.klijenti-tabela { display: none; } .klijenti-kartice { display: flex; }
|
||||||
.magacin-tabela { display: none; } .magacin-kartice { display: flex; }
|
.magacin-tabela { display: none; } .magacin-kartice { display: flex; }
|
||||||
.servis-tabela { display: none; } .servis-kartice { display: flex; }
|
.servis-tabela { display: none; } .servis-kartice { display: flex; }
|
||||||
|
.usluge-tabela { display: none; } .usluge-kartice { display: flex; }
|
||||||
.prodaja-tabela { display: none; } .prodaja-kartice { display: flex; }
|
.prodaja-tabela { display: none; } .prodaja-kartice { display: flex; }
|
||||||
.pod-tabela { display: none; } .pod-kartice { display: flex; }
|
.pod-tabela { display: none; } .pod-kartice { display: flex; }
|
||||||
.stavke-tabela-wrapper { display: none; } .stavke-kartice { display: flex; }
|
.stavke-tabela-wrapper { display: none; } .stavke-kartice { display: flex; }
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "naslov"}}Usluge — NTech{{end}}
|
||||||
|
|
||||||
|
{{define "sadrzaj"}}
|
||||||
|
<div class="kolona" style="gap:16px;">
|
||||||
|
|
||||||
|
{{if .Sacuvano}}
|
||||||
|
<div class="poruka-uspeh poruka-animacija">Usluga je uspešno sačuvana.</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Obrisan}}
|
||||||
|
<div class="poruka-uspeh poruka-animacija">Usluga je uspešno obrisana.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- dugme nova -->
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||||
|
{{if index .Dozvole "artikal.dodaj"}}
|
||||||
|
<a href="/usluge/nova" class="btn-primarno">+ Nova usluga</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- pretraga -->
|
||||||
|
<form method="GET" action="/usluge" class="kolona" style="gap:10px;">
|
||||||
|
<input type="text" name="pretraga" value="{{.Pretraga}}"
|
||||||
|
placeholder="Pretraži usluge po nazivu ili šifri..."
|
||||||
|
style="width:100%;"
|
||||||
|
hx-get="/usluge" hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search"
|
||||||
|
hx-target="#usluge-rezultati" hx-select="#usluge-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="usluge-rezultati">
|
||||||
|
|
||||||
|
<!-- desktop tabela -->
|
||||||
|
<div class="kartica usluge-tabela animiraj" style="padding:0;overflow:hidden;">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="tabela">
|
||||||
|
<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);">Šifra</th>
|
||||||
|
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Naziv</th>
|
||||||
|
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">JM</th>
|
||||||
|
<th style="padding:12px 16px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Cena</th>
|
||||||
|
<th style="padding:12px 16px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">PDV</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 .Usluge}}
|
||||||
|
<tr class="animiraj red-tabele">
|
||||||
|
<td style="padding:12px 16px;font-size:13px;font-family:monospace;white-space:nowrap;color:var(--tekst-sporedni);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
|
||||||
|
<td style="padding:12px 16px;font-size:14px;color:var(--tekst-glavni);font-weight:500;">{{.Naziv}}</td>
|
||||||
|
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">{{.JedinicaMere}}</td>
|
||||||
|
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{dinari .Cena}} din</td>
|
||||||
|
<td style="padding:12px 16px;text-align:center;font-size:13px;color:var(--tekst-sporedni);">{{.PdvStopa}}%</td>
|
||||||
|
<td style="padding:12px 16px;text-align:center;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
|
||||||
|
<a href="/usluge/izmeni/{{.ID}}" class="btn-primarno-malo">Izmeni</a>
|
||||||
|
{{if index $.Dozvole "artikal.obrisi"}}
|
||||||
|
<form method="POST" action="/usluge/obrisi/{{.ID}}" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
|
||||||
|
<button type="submit" class="btn-obrisi-malo"
|
||||||
|
data-potvrda="Obrisati uslugu '{{.Naziv}}'?">Obriši</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
|
||||||
|
{{if $.Pretraga}}Za datu pretragu nema usluga u bazi.{{else}}Nema usluga. <a href="/usluge/nova" style="color:var(--sb-akcent);">Dodaj prvu uslugu.</a>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mobilne kartice -->
|
||||||
|
<div class="usluge-kartice">
|
||||||
|
{{range .Usluge}}
|
||||||
|
<div class="kartica animiraj">
|
||||||
|
<div class="red-izmedju" style="align-items:flex-start;gap:12px;margin-bottom:8px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</div>
|
||||||
|
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.JedinicaMere}}</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:15px;font-weight:600;color:var(--sb-akcent);white-space:nowrap;">{{dinari .Cena}} din</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<a href="/usluge/izmeni/{{.ID}}" class="btn-primarno-malo">Izmeni</a>
|
||||||
|
{{if index $.Dozvole "artikal.obrisi"}}
|
||||||
|
<form method="POST" action="/usluge/obrisi/{{.ID}}" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
|
||||||
|
<button type="submit" class="btn-obrisi-malo" data-potvrda="Obrisati uslugu '{{.Naziv}}'?">Obriši</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
|
||||||
|
{{if $.Pretraga}}Za datu pretragu nema usluga u bazi.{{else}}Nema usluga. <a href="/usluge/nova" style="color:var(--sb-akcent);">Dodaj prvu uslugu.</a>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "naslov"}}{{if .Izmena}}Izmena usluge{{else}}Nova usluga{{end}} — NTech{{end}}
|
||||||
|
|
||||||
|
{{define "sadrzaj"}}
|
||||||
|
<div class="kolona" style="gap:16px;max-width:680px;">
|
||||||
|
|
||||||
|
<a href="/usluge" class="nazad-link">
|
||||||
|
<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" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
Nazad na usluge
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{if .Greska}}
|
||||||
|
<div class="poruka-greska">{{.Greska}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="{{if .Izmena}}/usluge/izmeni/{{.Usluga.ID}}{{else}}/usluge/nova{{end}}">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
|
<div class="kartica animiraj">
|
||||||
|
<div style="margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{if .Izmena}}Izmena usluge{{else}}Nova usluga{{end}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kolona" style="gap:14px;">
|
||||||
|
<div class="forma-grid-2">
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Naziv usluge</label>
|
||||||
|
<input type="text" name="naziv" value="{{.Usluga.Naziv}}" style="width:100%;"
|
||||||
|
placeholder="npr. Čišćenje laptopa">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Šifra</label>
|
||||||
|
<input type="text" name="sifra" value="{{.Usluga.Sifra}}" style="width:100%;"
|
||||||
|
placeholder="npr. USL-001">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="forma-grid-3">
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Jedinica mere</label>
|
||||||
|
{{$jm := .Usluga.JedinicaMere}}
|
||||||
|
<select name="jedinica_mere" style="width:100%;">
|
||||||
|
<option value="usluga" {{if eq $jm "usluga"}}selected{{end}}>usluga (fiksno)</option>
|
||||||
|
<option value="sat" {{if eq $jm "sat"}}selected{{end}}>sat</option>
|
||||||
|
<option value="kom" {{if eq $jm "kom"}}selected{{end}}>kom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Cena usluge (din)</label>
|
||||||
|
<input type="number" name="cena" min="0" step="0.01"
|
||||||
|
value="{{if .Usluga.Cena}}{{.Usluga.Cena}}{{end}}" placeholder="0" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">PDV stopa</label>
|
||||||
|
{{$pdv := .Usluga.PdvStopa}}
|
||||||
|
<select name="pdv_stopa" style="width:100%;">
|
||||||
|
<option value="20" {{if eq $pdv 20.0}}selected{{end}}>20%</option>
|
||||||
|
<option value="10" {{if eq $pdv 10.0}}selected{{end}}>10%</option>
|
||||||
|
<option value="0" {{if eq $pdv 0.0}}selected{{end}}>0%</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Opis</label>
|
||||||
|
<textarea name="opis" rows="2" placeholder="Kratak opis usluge..."
|
||||||
|
style="width:100%;resize:vertical;">{{.Usluga.Opis}}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
|
||||||
|
<a href="/usluge" class="btn-sekundarno">Odustani</a>
|
||||||
|
<button type="submit" class="btn-primarno">Sačuvaj uslugu</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user