Artikli: šifre, tip i jedinica mere; magacin UI; servis predračun
Šifre artikala: - Kôd kategorije kao prefiks auto-šifre (PREFIKS-NNNN), otporno na brisanje (max+1) - Tip artikla (proizvod/usluga/trošak) i jedinica mere - Arhiviranje artikala umesto brisanja kad su već u prometu Magacin: - Paginacija 50 po stranici - Klikabilna šifra (vodi na karticu), opisniji placeholder pretrage - Ispravka: pretraga više ne okida animaciju redova (globalni htmx listener umesto hx-on atributa koji se ne okida u ovoj htmx verziji) - Dugmad akcija ne prelamaju tekst; uklonjen content-visibility (secanje pri skrolu) Servis: predračun (nova stranica i ruta)
This commit is contained in:
@@ -65,6 +65,7 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
k := &model.Kategorija{
|
||||
Naziv: naziv,
|
||||
Opis: r.FormValue("opis"),
|
||||
Kod: normalizujKod(r.FormValue("kod")),
|
||||
Marza: parsirajMarzu(r.FormValue("marza")),
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
ID: id,
|
||||
Naziv: naziv,
|
||||
Opis: r.FormValue("opis"),
|
||||
Kod: normalizujKod(r.FormValue("kod")),
|
||||
Marza: parsirajMarzu(r.FormValue("marza")),
|
||||
}
|
||||
|
||||
@@ -126,6 +128,19 @@ func parsirajMarzu(s string) *float64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
// normalizujKod čisti kôd kategorije za upotrebu kao prefiks šifre:
|
||||
// velika slova, zadržava samo slova i brojeve (bez razmaka i specijalnih znakova).
|
||||
func normalizujKod(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ObrisiKategoriju briše kategoriju po ID-u
|
||||
func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok {
|
||||
|
||||
@@ -39,7 +39,7 @@ var saSidebar = []string{
|
||||
|
||||
// standalone su šabloni bez base layouta
|
||||
var standaloneIme = []string{
|
||||
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_status_javni",
|
||||
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_predracun", "servis_status_javni",
|
||||
}
|
||||
|
||||
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -20,6 +21,10 @@ type PodaciMagacina struct {
|
||||
KategorijaIDStr string
|
||||
Sacuvano bool
|
||||
Obrisan bool
|
||||
Arhiviran bool
|
||||
Vracen bool
|
||||
Greska bool
|
||||
PrikazArhivirani bool // true → lista prikazuje arhivirane umesto aktivnih
|
||||
Premesten bool
|
||||
StranicaBr int
|
||||
UkupnoStranica int
|
||||
@@ -40,6 +45,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
||||
filter := db.ArtikalFilter{
|
||||
Pretraga: r.URL.Query().Get("pretraga"),
|
||||
SamoKriticni: r.URL.Query().Get("kriticni") == "1",
|
||||
Arhivirani: r.URL.Query().Get("arhivirani") == "1",
|
||||
}
|
||||
|
||||
katIDStr := ""
|
||||
@@ -51,7 +57,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = 100
|
||||
const pageSize = 50
|
||||
stranicaBr := 1
|
||||
if p := r.URL.Query().Get("stranica"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil && v > 0 {
|
||||
@@ -96,6 +102,9 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
||||
if filter.SamoKriticni {
|
||||
queryDelići += "&kriticni=1"
|
||||
}
|
||||
if filter.Arhivirani {
|
||||
queryDelići += "&arhivirani=1"
|
||||
}
|
||||
|
||||
stranicaPrev := stranicaBr - 1
|
||||
if stranicaPrev < 1 {
|
||||
@@ -114,6 +123,10 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
||||
KategorijaIDStr: katIDStr,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
Obrisan: r.URL.Query().Get("obrisan") == "1",
|
||||
Arhiviran: r.URL.Query().Get("arhiviran") == "1",
|
||||
Vracen: r.URL.Query().Get("vracen") == "1",
|
||||
Greska: r.URL.Query().Get("greska") == "1",
|
||||
PrikazArhivirani: filter.Arhivirani,
|
||||
Premesten: r.URL.Query().Get("premesten") == "1",
|
||||
StranicaBr: stranicaBr,
|
||||
UkupnoStranica: ukupnoStranica,
|
||||
@@ -169,18 +182,46 @@ func (h *Handler) ObrisiArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.Artikli.Obrisi(r.Context(), id); err != nil {
|
||||
http.Error(w, "Greška pri brisanju artikla", http.StatusInternalServerError)
|
||||
// artikal je u prometu — ne brišemo ga, već ga arhiviramo
|
||||
if errors.Is(err, db.ErrArtikalUUpotrebi) {
|
||||
if err := h.Artikli.Arhiviraj(r.Context(), id); err != nil {
|
||||
http.Redirect(w, r, "/magacin?greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/magacin?arhiviran=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/magacin?greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/magacin?obrisan=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// VratiArtikal poništava arhiviranje i vraća artikal u aktivnu listu
|
||||
func (h *Handler) VratiArtikal(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 artikla", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Artikli.Vrati(r.Context(), id); err != nil {
|
||||
http.Redirect(w, r, "/magacin?arhivirani=1&greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/magacin?arhivirani=1&vracen=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// PodaciMagacinskeKartice su podaci za karticu jednog artikla
|
||||
type PodaciMagacinskeKartice struct {
|
||||
model.PodaciStranice
|
||||
Artikal model.Artikal
|
||||
Promene []model.MagacinskaPromenaSaDetaljem
|
||||
Artikal model.Artikal
|
||||
Promene []model.MagacinskaPromenaSaDetaljem
|
||||
}
|
||||
|
||||
// MagacinskaKartica prikazuje sve promene stanja za jedan artikal
|
||||
|
||||
@@ -37,10 +37,10 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
predlogSifre, err := h.Artikli.SledecaSifra(r.Context())
|
||||
predlogSifre, err := h.Artikli.SledecaSifra(r.Context(), nil)
|
||||
if err != nil {
|
||||
slog.Error("greška pri generisanju predloga šifre", "err", err)
|
||||
predlogSifre = "ART-00001"
|
||||
predlogSifre = "ART-0001"
|
||||
}
|
||||
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
@@ -49,7 +49,7 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||
PodaciStranice: ps,
|
||||
Kategorije: kategorije,
|
||||
Artikal: model.Artikal{Sifra: predlogSifre},
|
||||
Artikal: model.Artikal{Sifra: predlogSifre, Tip: model.TipProizvod, JedinicaMere: "kom"},
|
||||
Izmena: false,
|
||||
})
|
||||
}
|
||||
@@ -94,9 +94,12 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ako korisnik nije uneo šifru, auto-generišemo po ID-u
|
||||
// ako korisnik nije uneo šifru, auto-generišemo po prefiksu kategorije
|
||||
if artikal.Sifra == "" {
|
||||
autoSifra := fmt.Sprintf("ART-%05d", id)
|
||||
autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID)
|
||||
if e != nil {
|
||||
autoSifra = fmt.Sprintf("ART-%04d", id)
|
||||
}
|
||||
artikal.ID = id
|
||||
artikal.Sifra = autoSifra
|
||||
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
|
||||
@@ -244,6 +247,22 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
artikal.Lokacija = r.FormValue("lokacija")
|
||||
artikal.Napomena = r.FormValue("napomena")
|
||||
|
||||
// tip artikla — podrazumevano proizvod; usluga i trošak ne prate lager
|
||||
switch r.FormValue("tip") {
|
||||
case model.TipUsluga:
|
||||
artikal.Tip = model.TipUsluga
|
||||
case model.TipTrosak:
|
||||
artikal.Tip = model.TipTrosak
|
||||
default:
|
||||
artikal.Tip = model.TipProizvod
|
||||
}
|
||||
|
||||
// jedinica mere — podrazumevano "kom"
|
||||
artikal.JedinicaMere = r.FormValue("jedinica_mere")
|
||||
if artikal.JedinicaMere == "" {
|
||||
artikal.JedinicaMere = "kom"
|
||||
}
|
||||
|
||||
if k := r.FormValue("kolicina"); k != "" {
|
||||
v, err := strconv.Atoi(k)
|
||||
if err != nil || v < 0 {
|
||||
@@ -260,6 +279,12 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
artikal.KolicinMin = v
|
||||
}
|
||||
|
||||
// usluge i troškovi nemaju stanje na lageru
|
||||
if !artikal.PratiLager() {
|
||||
artikal.Kolicina = 0
|
||||
artikal.KolicinMin = 0
|
||||
}
|
||||
|
||||
if c := r.FormValue("nabavna_cena"); c != "" {
|
||||
v, err := strconv.ParseFloat(c, 64)
|
||||
if err != nil || v < 0 {
|
||||
@@ -295,6 +320,23 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
return artikal, ""
|
||||
}
|
||||
|
||||
// PredlogSifre vraća predlog auto-šifre za izabranu kategoriju (poziva forma pri promeni kategorije)
|
||||
func (h *Handler) PredlogSifre(w http.ResponseWriter, r *http.Request) {
|
||||
var kategorijaID *int64
|
||||
if v := r.URL.Query().Get("kategorija"); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
kategorijaID = &id
|
||||
}
|
||||
}
|
||||
sifra, err := h.Artikli.SledecaSifra(r.Context(), kategorijaID)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri generisanju šifre", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(sifra))
|
||||
}
|
||||
|
||||
// renderujFormuArtikla renderuje HTML formu za artikal
|
||||
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
|
||||
h.renderujTemplate(w, "magacin_forma", podaci)
|
||||
|
||||
@@ -44,6 +44,7 @@ type PodaciPodesavanja struct {
|
||||
BackupBrojKopija string
|
||||
KalkulacijaMarza string
|
||||
ServisGarancijaMeseci string
|
||||
PredracunRokDana string
|
||||
LoginPozadina string
|
||||
LoginPozadinaOpacity string
|
||||
LoginPozadinaBlurPozadine string
|
||||
@@ -332,6 +333,20 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// rok važenja predračuna u danima (1–90)
|
||||
if v := strings.TrimSpace(r.FormValue("predracun_rok_dana")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 || n > 90 {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Rok važenja predračuna mora biti broj između 1 i 90 dana.")
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "predracun_rok_dana", strconv.Itoa(n)); err != nil {
|
||||
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, sledeci+"?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -700,6 +715,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
|
||||
BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
|
||||
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
|
||||
ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"),
|
||||
PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -782,6 +782,154 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// PodaciPredracuna su podaci za predračun/ponudu koja se šalje klijentu pre rada
|
||||
type PodaciPredracuna struct {
|
||||
Nalog model.ServisniNalog
|
||||
ServisniDelovi []model.ServisniDeoSaArtiklom
|
||||
UkupnoDelovi float64
|
||||
|
||||
ImaCenuRada bool // ima li nalog uopšte cenu rada (raspon ili fiksnu)
|
||||
CenaRaspon bool // true → prikaži procenu Od–Do; false → fiksnu cenu
|
||||
CenaRadaOd float64 // donja granica procene
|
||||
CenaRadaDo float64 // gornja granica procene
|
||||
CenaRada float64 // fiksna cena rada
|
||||
|
||||
UkupnoOd float64 // delovi + CenaRadaOd (kad je raspon)
|
||||
UkupnoDo float64 // delovi + CenaRadaDo (kad je raspon)
|
||||
Ukupno float64 // delovi + fiksna cena (ili samo delovi ako nema cene rada)
|
||||
|
||||
DatumIzdavanja time.Time
|
||||
VaziDo time.Time
|
||||
|
||||
QRKod string
|
||||
Klijent *model.Klijent
|
||||
KlijentNaziv string
|
||||
TehnicarNaziv string
|
||||
NazivFirme string
|
||||
Podnazlov string
|
||||
Adresa string
|
||||
Telefon string
|
||||
PIB string
|
||||
}
|
||||
|
||||
// StampaPredracuna renderuje predračun/ponudu koja se šalje klijentu kada se utvrdi kvar
|
||||
func (h *Handler) StampaPredracuna(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.ServisRepo.DohvatiID(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Nalog nije pronađen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju delova", 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
|
||||
}
|
||||
|
||||
var klijent *model.Klijent
|
||||
klijentNaziv := ""
|
||||
if nalog.KlijentID != nil {
|
||||
k, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
|
||||
if err == nil {
|
||||
klijent = k
|
||||
if k.NazivFirme != "" {
|
||||
klijentNaziv = k.NazivFirme
|
||||
} else {
|
||||
klijentNaziv = strings.TrimSpace(k.Ime + " " + k.Prezime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tehnicarNaziv := ""
|
||||
if nalog.TehnicarID != nil {
|
||||
tehnicar, err := h.KorisniciRepo.DohvatiPoID(r.Context(), *nalog.TehnicarID)
|
||||
if err == nil {
|
||||
tehnicarNaziv = tehnicar.KorisnickoIme
|
||||
}
|
||||
}
|
||||
|
||||
var ukupnoDelovi float64
|
||||
for _, d := range delovi {
|
||||
ukupnoDelovi += d.Ukupno()
|
||||
}
|
||||
|
||||
// cena rada: ako postoji procena raspona (Od i Do) prikaži je, inače padni na fiksnu cenu
|
||||
var imaCenuRada, cenaRaspon bool
|
||||
var cenaRadaOd, cenaRadaDo, cenaRada float64
|
||||
var ukupnoOd, ukupnoDo, ukupno float64
|
||||
switch {
|
||||
case nalog.CenaOd != nil && nalog.CenaDo != nil:
|
||||
imaCenuRada = true
|
||||
cenaRaspon = true
|
||||
cenaRadaOd = *nalog.CenaOd
|
||||
cenaRadaDo = *nalog.CenaDo
|
||||
ukupnoOd = ukupnoDelovi + cenaRadaOd
|
||||
ukupnoDo = ukupnoDelovi + cenaRadaDo
|
||||
case nalog.CenaKonacna != nil:
|
||||
imaCenuRada = true
|
||||
cenaRada = *nalog.CenaKonacna
|
||||
ukupno = ukupnoDelovi + cenaRada
|
||||
default:
|
||||
ukupno = ukupnoDelovi
|
||||
}
|
||||
|
||||
// rok važenja iz podešavanja (default 7 dana)
|
||||
rok := 7
|
||||
if v, err := strconv.Atoi(podesavanja["predracun_rok_dana"]); err == nil && v > 0 {
|
||||
rok = v
|
||||
}
|
||||
datumIzdavanja := time.Now()
|
||||
vaziDo := datumIzdavanja.AddDate(0, 0, rok)
|
||||
|
||||
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
|
||||
nalogURL := "http"
|
||||
if r.TLS != nil {
|
||||
nalogURL += "s"
|
||||
}
|
||||
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
|
||||
var qrKod string
|
||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||
qrKod = base64.StdEncoding.EncodeToString(png)
|
||||
}
|
||||
|
||||
h.renderujStandalone(w, "servis_predracun", PodaciPredracuna{
|
||||
Nalog: *nalog,
|
||||
ServisniDelovi: delovi,
|
||||
UkupnoDelovi: ukupnoDelovi,
|
||||
ImaCenuRada: imaCenuRada,
|
||||
CenaRaspon: cenaRaspon,
|
||||
CenaRadaOd: cenaRadaOd,
|
||||
CenaRadaDo: cenaRadaDo,
|
||||
CenaRada: cenaRada,
|
||||
UkupnoOd: ukupnoOd,
|
||||
UkupnoDo: ukupnoDo,
|
||||
Ukupno: ukupno,
|
||||
DatumIzdavanja: datumIzdavanja,
|
||||
VaziDo: vaziDo,
|
||||
QRKod: qrKod,
|
||||
Klijent: klijent,
|
||||
KlijentNaziv: klijentNaziv,
|
||||
TehnicarNaziv: tehnicarNaziv,
|
||||
NazivFirme: podesavanja["naziv_firme"],
|
||||
Podnazlov: podesavanja["podnazlov"],
|
||||
Adresa: podesavanja["adresa"],
|
||||
Telefon: podesavanja["telefon"],
|
||||
PIB: podesavanja["pib"],
|
||||
})
|
||||
}
|
||||
|
||||
// PromeniStatus obrađuje POST /servis/{id}/status i menja samo status naloga
|
||||
func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
|
||||
Reference in New Issue
Block a user