Compare commits

...
10 Commits
19 changed files with 885 additions and 133 deletions
+2 -1
View File
@@ -350,7 +350,7 @@ func main() {
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka)
r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/{id}", h.DetaljiNabavke)
r.With(doz("nabavka.obrisi")).Post("/nabavke/obrisi/{id}", h.ObrisiNabavku)
r.With(doz("nabavka.storno")).Post("/nabavke/storno/{id}", h.StornoNabavke)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "dobavljac.pregled")).Get("/dobavljaci", h.Dobavljaci)
r.Get("/dobavljaci/novi", h.NoviDobavljac)
r.With(doz("dobavljac.dodaj")).Post("/dobavljaci/novi", h.SacuvajDobavljaca)
@@ -400,6 +400,7 @@ func main() {
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
r.Get("/izvestaji/popis", h.Popis)
r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis)
r.Get("/izvestaji/popis/stampa", h.PopisStampa)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
r.Get("/prodaja/nova", h.NovaProdaja)
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
+3 -3
View File
@@ -29,8 +29,8 @@ type ArtikalRepository interface {
Vrati(ctx context.Context, id int64) error
// SledecaSifra vraća predlog sledeće auto-šifre (npr. KOMP-0042 ili ART-0042)
SledecaSifra(ctx context.Context, kategorijaID *int64) (string, error)
// KorigujKolicinu postavlja novu količinu artikla i upisuje korekciju u magacinske_promene
KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error
// KorigujKolicinu postavlja novu količinu artikla i upisuje promenu u magacinske_promene
KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena, tipPromene string) error
// DobavljaciArtikla vraća ID-jeve dobavljača vezanih za artikal
DobavljaciArtikla(ctx context.Context, artikalID int64) ([]int64, error)
// PostaviDobavljaceArtikla zamenjuje skup dobavljača artikla datim ID-jevima
@@ -152,7 +152,7 @@ type NabavkaRepository interface {
DohvatiStavke(ctx context.Context, nabavkaID int64) ([]model.StavkaSaArtiklom, error)
DohvatiTroskove(ctx context.Context, nabavkaID int64) ([]model.NabavkaTrosak, error)
Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak, korisnikID *int64) (int64, error)
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
}
// DobavljacRepository definiše operacije nad dobavljačima
+4 -3
View File
@@ -397,8 +397,9 @@ func (r *ArtikalRepo) SveDobavljaceArtikala(ctx context.Context) (map[int64][]in
return mapa, nil
}
// KorigujKolicinu postavlja novu količinu i upisuje korekciju u magacinske_promene
func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error {
// KorigujKolicinu postavlja novu količinu i upisuje promenu u magacinske_promene;
// tipPromene određuje vrstu traga (korekcija, manjak_popis, visak_popis)
func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena, tipPromene string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: begin: %w", err)
@@ -418,7 +419,7 @@ func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, nova
}
promena := novaKolicina - staraKolicina
if err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaKorekcija, promena,
if err = zabeleziMagacinPromenu(ctx, tx, artikalID, tipPromene, promena,
staraKolicina, novaKolicina, 0, korisnikID, napomena); err != nil {
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: %w", err)
}
+53 -23
View File
@@ -22,7 +22,8 @@ func NoviNabavkaRepo(db *sql.DB) *NabavkaRepo {
func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT
n.id, n.dobavljac_id, n.napomena, n.ukupno, n.metod_raspodele, n.datum,
n.id, n.dobavljac_id, n.napomena, n.ukupno, n.metod_raspodele,
n.stornirano, n.razlog_storniranja, n.datum,
COALESCE(d.naziv, '') AS dobavljac_naziv
FROM nabavke n
LEFT JOIN dobavljaci d ON n.dobavljac_id = d.id
@@ -36,10 +37,11 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err
for redovi.Next() {
var n model.NabavkaSaDetaljem
var dobavljacID sql.NullInt64
var napomena, metod sql.NullString
var napomena, metod, razlogStorniranja sql.NullString
err := redovi.Scan(
&n.ID, &dobavljacID, &napomena, &n.Ukupno, &metod, &n.Datum,
&n.ID, &dobavljacID, &napomena, &n.Ukupno, &metod,
&n.Stornirano, &razlogStorniranja, &n.Datum,
&n.DobavljacNaziv,
)
if err != nil {
@@ -51,6 +53,7 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err
}
n.Napomena = napomena.String
n.MetodRaspodele = metod.String
n.RazlogStorniranja = razlogStorniranja.String
rezultat = append(rezultat, n)
}
@@ -62,12 +65,14 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err
func (r *NabavkaRepo) DohvatiID(ctx context.Context, id int64) (*model.Nabavka, error) {
var n model.Nabavka
var dobavljacID sql.NullInt64
var napomena, metod sql.NullString
var napomena, metod, razlogStorniranja sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, dobavljac_id, napomena, ukupno, metod_raspodele, datum
SELECT id, dobavljac_id, napomena, ukupno, metod_raspodele,
stornirano, razlog_storniranja, datum
FROM nabavke WHERE id = ?`, id).Scan(
&n.ID, &dobavljacID, &napomena, &n.Ukupno, &metod, &n.Datum,
&n.ID, &dobavljacID, &napomena, &n.Ukupno, &metod,
&n.Stornirano, &razlogStorniranja, &n.Datum,
)
if err != nil {
return nil, fmt.Errorf("ntech: NabavkaRepo.DohvatiID: %w", err)
@@ -78,6 +83,7 @@ func (r *NabavkaRepo) DohvatiID(ctx context.Context, id int64) (*model.Nabavka,
}
n.Napomena = napomena.String
n.MetodRaspodele = metod.String
n.RazlogStorniranja = razlogStorniranja.String
return &n, nil
}
@@ -194,17 +200,26 @@ func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []mo
}
var stanjePre int
var staraNabavnaCena float64
err = tx.QueryRowContext(ctx,
"SELECT kolicina FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&stanjePre)
"SELECT kolicina, nabavna_cena FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&stanjePre, &staraNabavnaCena)
if err != nil {
return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: dohvati stanje: %w", err)
}
// ponderisana prosečna nabavna cena (MRS 2)
stanjePosle := stanjePre + s.Kolicina
var novaProsecna float64
if stanjePosle > 0 {
novaProsecna = (float64(stanjePre)*staraNabavnaCena + float64(s.Kolicina)*s.CenaPoKomadu) / float64(stanjePosle)
} else {
novaProsecna = s.CenaPoKomadu
}
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?",
stanjePosle, s.ArtikalID,
"UPDATE artikli SET kolicina = ?, nabavna_cena = ? WHERE id = ?",
stanjePosle, novaProsecna, s.ArtikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: update kolicina: %w", err)
@@ -223,19 +238,31 @@ func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []mo
return nabavkaID, nil
}
// Obrisi briše nabavku po ID-u, vraća količine artikala na stanje i beleži korekciju u magacinski trag.
func (r *NabavkaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error {
// Storno označava nabavku kao storniranu, vraća količine artikala na stanje i beleži
// korekciju u magacinski trag. Nabavna cena se NE računa unazad — ostaje kakva jeste.
func (r *NabavkaRepo) Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Obrisi: begin: %w", err)
return fmt.Errorf("ntech: NabavkaRepo.Storno: begin: %w", err)
}
defer tx.Rollback()
// učitaj stavke pre brisanja (CASCADE ih briše zajedno sa nabavkom)
// provera da li je već stornirano
var stornirano bool
if err := tx.QueryRowContext(ctx,
"SELECT stornirano FROM nabavke WHERE id = ?", id,
).Scan(&stornirano); err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Storno: provera: %w", err)
}
if stornirano {
return fmt.Errorf("ntech: NabavkaRepo.Storno: nabavka je već stornirana")
}
// učitaj stavke
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM stavke_nabavke WHERE nabavka_id = ?", id)
if err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Obrisi: dohvati stavke: %w", err)
return fmt.Errorf("ntech: NabavkaRepo.Storno: dohvati stavke: %w", err)
}
type stavka struct {
artikalID int64
@@ -246,7 +273,7 @@ func (r *NabavkaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) e
var s stavka
if err := redovi.Scan(&s.artikalID, &s.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: NabavkaRepo.Obrisi: scan stavka: %w", err)
return fmt.Errorf("ntech: NabavkaRepo.Storno: scan stavka: %w", err)
}
stavke = append(stavke, s)
}
@@ -258,7 +285,7 @@ func (r *NabavkaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) e
if err := tx.QueryRowContext(ctx,
"SELECT kolicina FROM artikli WHERE id = ?", s.artikalID,
).Scan(&stanjePre); err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Obrisi: dohvati stanje: %w", err)
return fmt.Errorf("ntech: NabavkaRepo.Storno: dohvati stanje: %w", err)
}
stanjePosle := stanjePre - s.kolicina
if stanjePosle < 0 {
@@ -267,20 +294,23 @@ func (r *NabavkaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) e
if _, err := tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.artikalID,
); err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Obrisi: update stanje: %w", err)
return fmt.Errorf("ntech: NabavkaRepo.Storno: update stanje: %w", err)
}
if err := zabeleziMagacinPromenu(ctx, tx, s.artikalID, model.PromenaKorekcija,
-s.kolicina, stanjePre, stanjePosle, id, korisnikID, "brisanje nabavke"); err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Obrisi: magacin: %w", err)
-s.kolicina, stanjePre, stanjePosle, id, korisnikID, "storno nabavke: "+razlog); err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Storno: magacin: %w", err)
}
}
if _, err := tx.ExecContext(ctx, "DELETE FROM nabavke WHERE id = ?", id); err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Obrisi: delete: %w", err)
if _, err := tx.ExecContext(ctx,
"UPDATE nabavke SET stornirano = 1, razlog_storniranja = ? WHERE id = ?",
nullString(razlog), id,
); err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Storno: update nabavka: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: NabavkaRepo.Obrisi: commit: %w", err)
return fmt.Errorf("ntech: NabavkaRepo.Storno: commit: %w", err)
}
return nil
}
+190 -7
View File
@@ -316,9 +316,12 @@ func (h *Handler) StanjeZalihaIzvestaj(w http.ResponseWriter, r *http.Request) {
// PodaciPopisa su podaci za stranicu popisa
type PodaciPopisa struct {
model.PodaciStranice
Artikli []model.ArtikalSaKategorijom
Sacuvano bool
Greska string
Artikli []model.ArtikalSaKategorijom
Sacuvano bool
Greska string
TipPopisa string // godišnji | vanredni | preuzimanje
TipPopisaNaziv string // „Godišnji popis", „Vanredni popis", „Popis pri preuzimanju dužnosti"
DatumPopisa string // formatiran datum za prikaz
}
// Popis prikazuje formu za unos stvarnog stanja (inventuru)
@@ -327,7 +330,7 @@ func (h *Handler) Popis(w http.ResponseWriter, r *http.Request) {
return
}
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{})
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{Tip: model.TipProizvod})
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
@@ -337,14 +340,30 @@ func (h *Handler) Popis(w http.ResponseWriter, r *http.Request) {
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "izvestaji"
ps.NaslovStranice = "Popis"
tp := "godišnji"
h.renderujTemplate(w, "popis", PodaciPopisa{
PodaciStranice: ps,
Artikli: artikli,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
TipPopisa: tp,
TipPopisaNaziv: tipPopisaNaziv(tp),
DatumPopisa: time.Now().Format("02.01.2006."),
})
}
// tipPopisaNaziv vraća lokalizovani naziv za tip popisa
func tipPopisaNaziv(tp string) string {
switch tp {
case "vanredni":
return "Vanredni popis"
case "preuzimanje":
return "Popis pri preuzimanju dužnosti"
default:
return "Godišnji popis"
}
}
// SacuvajPopis prima POST formu sa prebrojenim količinama i upisuje korekcije
func (h *Handler) SacuvajPopis(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
@@ -357,15 +376,26 @@ func (h *Handler) SacuvajPopis(w http.ResponseWriter, r *http.Request) {
return
}
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{})
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{Tip: model.TipProizvod})
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
}
tipPopisa := r.FormValue("tip_popisa")
if tipPopisa == "" {
tipPopisa = "godišnji"
}
napomena := r.FormValue("napomena")
if napomena == "" {
napomena = "Godišnji popis"
switch tipPopisa {
case "vanredni":
napomena = "Vanredni popis"
case "preuzimanje":
napomena = "Popis pri preuzimanju dužnosti"
default:
napomena = "Godišnji popis"
}
}
var greskaBroj int
@@ -383,7 +413,11 @@ func (h *Handler) SacuvajPopis(w http.ResponseWriter, r *http.Request) {
continue
}
povecano := nova > a.Kolicina
if err := h.Artikli.KorigujKolicinu(r.Context(), a.ID, nova, &k.ID, napomena); err != nil {
tipPromene := model.PromenaManjak
if povecano {
tipPromene = model.PromenaVisak
}
if err := h.Artikli.KorigujKolicinu(r.Context(), a.ID, nova, &k.ID, napomena, tipPromene); err != nil {
slog.Error("popis: korekcija artikla", "id", a.ID, "error", err)
greskaBroj++
continue
@@ -413,9 +447,158 @@ func (h *Handler) SacuvajPopis(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
Artikli: artikli,
Greska: fmt.Sprintf("Došlo je do greške pri upisivanju %d artikala.", greskaBroj),
TipPopisa: tipPopisa,
TipPopisaNaziv: tipPopisaNaziv(tipPopisa),
DatumPopisa: time.Now().Format("02.01.2006."),
})
return
}
http.Redirect(w, r, "/izvestaji/popis?sacuvano=1", http.StatusSeeOther)
}
// PodaciPopisStampa su podaci za štampu popisa (standalone template)
type PodaciPopisStampa struct {
NazivFirme string
KorisnikIme string
TipPopisaNaziv string
DatumPopisa string
Redovi []PopisStampaRed
UkupnoKnjiznoKol int
UkupnoKnjiznoVredStr string
UkupnoStvarnoKol int
UkupnoRazlikaKolStr string
UkupnoRazlikaVredStr string
ManjakKol int
ManjakVredStr string
VisakKol int
VisakVredStr string
}
// PopisStampaRed je jedan red u štampi popisa
type PopisStampaRed struct {
Naziv string
Sifra string
KategorijaNaziv string
JedinicaMere string
NabavnaCenaStr string
Kolicina int
KnjiznaVrednostStr string
StvarnaKolicina int
Razlika int
RazlikaStr string
RazlikaVrednost float64
RazlikaVrednostStr string
}
// PopisStampa prikazuje standalone stranicu za štampu popisa
func (h *Handler) PopisStampa(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
return
}
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{Tip: model.TipProizvod})
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
tip := "godišnji"
var redovi []PopisStampaRed
var ukKnjKol, ukStvKol, ukRazKol, manjakKol, visakKol int
var ukKnjVred, ukRazVred, manjakVred, visakVred float64
for _, a := range artikli {
knjKol := a.Kolicina
cena := a.NabavnaCena
knjVred := cena * float64(knjKol)
stvKol := knjKol
razlika := stvKol - knjKol
razlikaVred := cena * float64(razlika)
ukKnjKol += knjKol
ukKnjVred += knjVred
ukStvKol += stvKol
ukRazKol += razlika
ukRazVred += razlikaVred
if razlika < 0 {
manjakKol += -razlika
manjakVred += -razlikaVred
} else if razlika > 0 {
visakKol += razlika
visakVred += razlikaVred
}
jm := a.JedinicaMere
if jm == "" {
jm = "kom"
}
razlikaStr := "—"
if razlika > 0 {
razlikaStr = fmt.Sprintf("+%d", razlika)
} else if razlika < 0 {
razlikaStr = fmt.Sprintf("%d", razlika)
}
razlikaVredStr := "—"
if razlikaVred != 0 {
predznak := ""
if razlikaVred > 0 {
predznak = "+"
}
razlikaVredStr = predznak + formatirajDinare(razlikaVred, 2)
}
redovi = append(redovi, PopisStampaRed{
Naziv: a.Naziv,
Sifra: a.Sifra,
KategorijaNaziv: a.KategorijaNaziv,
JedinicaMere: jm,
NabavnaCenaStr: formatirajDinare(cena, 0),
Kolicina: knjKol,
KnjiznaVrednostStr: formatirajDinare(knjVred, 2),
StvarnaKolicina: stvKol,
Razlika: razlika,
RazlikaStr: razlikaStr,
RazlikaVrednost: razlikaVred,
RazlikaVrednostStr: razlikaVredStr,
})
}
ukRazKolStr := "—"
if ukRazKol > 0 {
ukRazKolStr = fmt.Sprintf("+%d", ukRazKol)
} else if ukRazKol < 0 {
ukRazKolStr = fmt.Sprintf("%d", ukRazKol)
}
ukRazVredStr := "—"
if ukRazVred != 0 {
predznak := ""
if ukRazVred > 0 {
predznak = "+"
}
ukRazVredStr = predznak + formatirajDinare(ukRazVred, 2)
}
h.renderujStandalone(w, "popis_stampa", PodaciPopisStampa{
NazivFirme: podesavanja["naziv_firme"],
KorisnikIme: middleware.KorisnikIzKonteksta(r.Context()).KorisnickoIme,
TipPopisaNaziv: tipPopisaNaziv(tip),
DatumPopisa: time.Now().Format("02.01.2006."),
Redovi: redovi,
UkupnoKnjiznoKol: ukKnjKol,
UkupnoKnjiznoVredStr: formatirajDinare(ukKnjVred, 2),
UkupnoStvarnoKol: ukStvKol,
UkupnoRazlikaKolStr: ukRazKolStr,
UkupnoRazlikaVredStr: ukRazVredStr,
ManjakKol: manjakKol,
ManjakVredStr: formatirajDinare(manjakVred, 2),
VisakKol: visakKol,
VisakVredStr: formatirajDinare(visakVred, 2),
})
}
+1 -1
View File
@@ -43,7 +43,7 @@ var saSidebar = []string{
// standalone su šabloni bez base layouta
var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "fiskal_verifikacija",
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "fiskal_verifikacija", "popis_stampa",
}
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
+1 -1
View File
@@ -264,7 +264,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
// ako se količina promenila, koriguj stanje uz magacinski trag
if novaKolicina != staraKolicina {
if e := h.Artikli.KorigujKolicinu(r.Context(), id, novaKolicina, &k.ID, "izmena artikla"); e != nil {
if e := h.Artikli.KorigujKolicinu(r.Context(), id, novaKolicina, &k.ID, "izmena artikla", model.PromenaKorekcija); e != nil {
slog.Error("korekcija količine pri izmeni artikla nije uspela", "artikal_id", id, "error", e)
}
}
+22 -19
View File
@@ -43,6 +43,7 @@ type PodaciDetaljiNabavke struct {
Troskovi []model.NabavkaTrosak
UkupanTrosak float64
DobavljacNaziv string
Sacuvano bool
}
// artikalUJSON pretvara listu artikala u template.JS vrednost bezbednu za umetanje u <script> tag
@@ -208,20 +209,14 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
}
}
// kalkulacija: ažuriraj nabavnu i prodajnu cenu artikla iz forme + nivelacioni trag.
// kalkulacija: ažuriraj prodajnu cenu artikla iz forme + nivelacioni trag.
// nabavna cena je već ponderisana prosečna (postavlja je Kreiraj) — ne preglašava se.
// prodajna[] je paralelni niz uz stavke (isti redosled kao artikal_id[]).
prodajne := r.Form["prodajna[]"]
var korisnikID *int64
if k != nil {
korisnikID = &k.ID
}
// kalkulativna nabavna cena po stavci (fakturna + raspodeljeni zavisni trošak) —
// računa se na serveru; bez troškova je jednaka čistoj ceni po komadu.
var ukupanTrosak float64
for _, t := range troskovi {
ukupanTrosak += t.Iznos
}
kalkNabavna := model.RasporediTroskove(stavke, ukupanTrosak, nabavka.MetodRaspodele)
for i, s := range stavke {
if i >= len(prodajne) {
break
@@ -230,12 +225,13 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
if e != nil || prodajna <= 0 {
continue // prazno/nula ne sme da pregazi postojeću cenu
}
// stara prodajna cena — za nivelacioni zapis
var staraProdajna float64
// stara prodajna i tekuća (ponderisana) nabavna — nabavnu zadržavamo
var staraProdajna, nabavna float64
if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil {
staraProdajna = a.ProdajnaCena
nabavna = a.NabavnaCena
}
if e := h.Artikli.AzurirajCene(r.Context(), s.ArtikalID, kalkNabavna[i], prodajna); e != nil {
if e := h.Artikli.AzurirajCene(r.Context(), s.ArtikalID, nabavna, prodajna); e != nil {
slog.Error("kalkulacija: ažuriranje cena nije uspelo", "artikal_id", s.ArtikalID, "error", e)
continue
}
@@ -333,14 +329,15 @@ func (h *Handler) DetaljiNabavke(w http.ResponseWriter, r *http.Request) {
Troskovi: troskovi,
UkupanTrosak: ukupanTrosak,
DobavljacNaziv: dobavljacNaziv,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
h.renderujTemplate(w, "nabavka_detalji", podaci)
}
// ObrisiNabavku prima POST zahtev i briše nabavku po ID-u
func (h *Handler) ObrisiNabavku(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "nabavka.obrisi")
// StornoNabavke prima POST zahtev i stornira nabavku po ID-u (umesto fizičkog brisanja)
func (h *Handler) StornoNabavke(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "nabavka.storno")
if !ok {
return
}
@@ -349,17 +346,23 @@ func (h *Handler) ObrisiNabavku(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Neispravan ID nabavke", http.StatusBadRequest)
return
}
if err := h.NabavkeRepo.Obrisi(r.Context(), id, &k.ID); err != nil {
http.Error(w, "Greška pri brisanju nabavke", http.StatusInternalServerError)
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
// ukloni vezani auto-KPR zapis (ako ga je ova nabavka kreirala)
razlog := strings.TrimSpace(r.FormValue("razlog"))
if err := h.NabavkeRepo.Storno(r.Context(), id, razlog, &k.ID); err != nil {
slog.Error("greška pri storniranju nabavke", "error", err)
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
// stornirana nabavka ne ulazi u PDV — ukloni vezani auto-KPR zapis
if err := h.PdvKprRepo.ObrisiPoIzvoru(r.Context(), "nabavka", id); err != nil {
slog.Error("brisanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err)
}
http.Redirect(w, r, "/nabavke?obrisan=1", http.StatusSeeOther)
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// parseFormuNabavke čita zaglavlje, stavke i zavisne troškove iz HTTP forme
+2 -1
View File
@@ -15,6 +15,7 @@ var sveAkcije = []string{
"nabavka.pregled",
"nabavka.dodaj",
"nabavka.obrisi",
"nabavka.storno",
"dobavljac.pregled",
"dobavljac.dodaj",
"dobavljac.izmeni",
@@ -66,7 +67,7 @@ func ImaDozvolu(uloga, akcija string) bool {
case "kategorija.pregled", "kategorija.dodaj", "kategorija.izmeni", "kategorija.obrisi":
return true
// nabavka
case "nabavka.pregled", "nabavka.dodaj", "nabavka.obrisi":
case "nabavka.pregled", "nabavka.dodaj", "nabavka.obrisi", "nabavka.storno":
return true
// dobavljač
case "dobavljac.pregled", "dobavljac.dodaj",
+2
View File
@@ -9,6 +9,8 @@ const (
PromenaIzlazServis = "izlaz_servis"
PromenaPovracaj = "povracaj"
PromenaKorekcija = "korekcija"
PromenaManjak = "manjak_popis"
PromenaVisak = "visak_popis"
)
// MagacinskaPromenaSaDetaljem je promena stanja artikla sa nazivom artikla
+8 -6
View File
@@ -7,12 +7,14 @@ import (
// Nabavka predstavlja zaglavlje jedne nabavke
type Nabavka struct {
ID int64
DobavljacID *int64
Napomena string
Ukupno float64
MetodRaspodele string // "vrednost" ili "kolicina"; prazno = nema zavisnih troškova
Datum time.Time
ID int64
DobavljacID *int64
Napomena string
Ukupno float64
MetodRaspodele string // "vrednost" ili "kolicina"; prazno = nema zavisnih troškova
Stornirano bool
RazlogStorniranja string
Datum time.Time
}
// NabavkaTrosak je jedna stavka zavisnih troškova nabavke (npr. prevoz, carina)
+25
View File
@@ -0,0 +1,25 @@
-- Proširuje CHECK constraint na magacinske_promene da uključi manjak_popis i visak_popis
-- SQLite ne podržava ALTER TABLE za CHECK, pa rekreiramo tabelu
PRAGMA foreign_keys=OFF;
CREATE TABLE magacinske_promene_nova (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artikal_id INTEGER NOT NULL REFERENCES artikli(id) ON DELETE RESTRICT,
tip_promene TEXT NOT NULL CHECK (tip_promene IN (
'ulaz_nabavka', 'izlaz_prodaja', 'izlaz_servis',
'povracaj', 'korekcija', 'manjak_popis', 'visak_popis'
)),
referentni_id INTEGER NOT NULL,
promena_kolicine INTEGER NOT NULL,
stanje_pre INTEGER NOT NULL,
stanje_posle INTEGER NOT NULL,
korisnik_id INTEGER REFERENCES korisnici(id) ON DELETE SET NULL,
napomena TEXT,
datum DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO magacinske_promene_nova SELECT * FROM magacinske_promene;
DROP TABLE magacinske_promene;
ALTER TABLE magacinske_promene_nova RENAME TO magacinske_promene;
PRAGMA foreign_keys=ON;
+3
View File
@@ -0,0 +1,3 @@
-- Nabavke — storniranje umesto fizičkog brisanja (računovodstveni tretman ulaznih dokumenata)
ALTER TABLE nabavke ADD COLUMN stornirano INTEGER NOT NULL DEFAULT 0;
ALTER TABLE nabavke ADD COLUMN razlog_storniranja TEXT;
@@ -129,6 +129,10 @@
<span class="bedz" style="background:rgba(168,85,247,0.12);color:#a855f7;">Povraćaj</span>
{{else if eq .TipPromene "korekcija"}}
<span class="bedz" style="background:rgba(156,163,175,0.12);color:#9ca3af;">Korekcija</span>
{{else if eq .TipPromene "manjak_popis"}}
<span class="bedz" style="background:rgba(220,38,38,0.12);color:#dc2626;">Manjak (popis)</span>
{{else if eq .TipPromene "visak_popis"}}
<span class="bedz" style="background:rgba(34,197,94,0.12);color:#22c55e;">Višak (popis)</span>
{{else}}
<span class="bedz">{{.TipPromene}}</span>
{{end}}
+29 -8
View File
@@ -14,6 +14,10 @@
{{define "sadrzaj"}}
<div class="kolona" style="gap:16px;">
{{if .Sacuvano}}
<div class="poruka-uspeh poruka-animacija">Nabavka je uspešno sačuvana.</div>
{{end}}
<!-- nazad dugme -->
<a href="/nabavke" class="nazad-link" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
<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>
@@ -23,7 +27,14 @@
<!-- zaglavlje nabavke -->
<div class="kartica detalji-kartica animiraj">
<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Detalji nabavke</span>
<span style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">Detalji nabavke</span>
{{if .Nabavka.Stornirano}}
<span style="display:inline-block;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:500;background:rgba(220,38,38,0.12);color:#dc2626;">
Stornirano
</span>
{{end}}
</span>
<span class="pomocni-tekst">{{.Nabavka.Datum.Format "02.01.2006. u 15:04"}}</span>
</div>
@@ -46,6 +57,12 @@
{{dinari .Nabavka.Ukupno}} din
</div>
</div>
{{if .Nabavka.RazlogStorniranja}}
<div style="grid-column:1/-1;">
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Razlog storniranja</div>
<div style="font-size:13px;color:#dc2626;">{{.Nabavka.RazlogStorniranja}}</div>
</div>
{{end}}
</div>
</div>
@@ -146,23 +163,27 @@
</div>
{{end}}
<!-- zona za brisanje -->
<!-- zona za storniranje -->
{{if and (index .Dozvole "nabavka.storno") (not .Nabavka.Stornirano)}}
<div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
<div style="font-size:14px;font-weight:500;color:#dc2626;margin-bottom:4px;">Brisanje nabavke</div>
<div style="font-size:14px;font-weight:500;color:#f97316;margin-bottom:4px;">Storniranje nabavke</div>
<div class="pomocni-tekst">
Brisanje je trajno i ne vraća količine artikala u magacin.
Storno vraća količine artikala na stanje u magacinu. Nabavka ostaje u evidenciji sa oznakom stornirana. Nabavna cena se ne menja.
</div>
</div>
<form method="POST" action="/nabavke/obrisi/{{.Nabavka.ID}}">
<button type="submit" class="btn-opasno"
data-potvrda="Da li ste sigurni da želite da obrišete ovu nabavku? Brisanje ne vraća količine artikala u magacin.">
Obriši nabavku
<form method="POST" action="/nabavke/storno/{{.Nabavka.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="text" name="razlog" placeholder="Razlog storniranja (opciono)" style="width:240px;">
<button type="submit" class="btn-upozorenje"
data-potvrda="Stornirati ovu nabavku? Količine artikala biće vraćene na stanje.">
Storniraj nabavku
</button>
</form>
</div>
</div>
{{end}}
</div>
{{end}}
+6 -18
View File
@@ -37,7 +37,10 @@
{{.Datum.Format "02.01.2006."}}
</td>
<td style="padding:12px 16px;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{if .DobavljacNaziv}}{{.DobavljacNaziv}}{{else}}—{{end}}
<span style="display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap;">
{{if .DobavljacNaziv}}{{.DobavljacNaziv}}{{else}}—{{end}}
{{if .Stornirano}}<span style="padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500;background:rgba(220,38,38,0.12);color:#dc2626;">Stornirano</span>{{end}}
</span>
</td>
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
{{if .Napomena}}{{.Napomena}}{{else}}—{{end}}
@@ -50,14 +53,6 @@
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/nabavke/{{.ID}}" class="btn-primarno-malo">
Detalji
</a>
{{if index $.Dozvole "nabavka.obrisi"}}
<form method="POST" action="/nabavke/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni? Brisanje nabavke ne vraća količine artikala u magacin.">
Obriši
</button>
</form>
{{end}}
</div>
</td>
</tr>
@@ -79,8 +74,9 @@
<div class="kartica nabavka-kartica animiraj">
<div class="red-izmedju" style="align-items:flex-start;gap:12px;margin-bottom:10px;">
<div>
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
{{if .DobavljacNaziv}}{{.DobavljacNaziv}}{{else}}Bez dobavljača{{end}}
{{if .Stornirano}}<span style="padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500;background:rgba(220,38,38,0.12);color:#dc2626;">Stornirano</span>{{end}}
</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">
{{.Datum.Format "02.01.2006."}}
@@ -97,14 +93,6 @@
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/nabavke/{{.ID}}" class="btn-primarno-malo" style="flex:1;justify-content:center;">
Detalji
</a>
{{if index $.Dozvole "nabavka.obrisi"}}
<form method="POST" action="/nabavke/obrisi/{{.ID}}" style="flex:1;">
<button type="submit" class="btn-obrisi-malo" style="width:100%;justify-content:center;"
data-potvrda="Da li ste sigurni? Brisanje nabavke ne vraća količine artikala u magacin.">
Obriši
</button>
</form>
{{end}}
</div>
</div>
{{else}}
+361 -42
View File
@@ -5,7 +5,7 @@
{{define "dodatni-css"}}
<style>
.popis-input {
width: 80px;
width: 90px;
text-align: right;
font-family: monospace;
font-size: 14px;
@@ -15,7 +15,7 @@
border-color: #f97316;
background: rgba(249,115,22,0.06);
}
.popis-razlika {
.popis-razlika, .popis-vrednost-razlika {
font-family: monospace;
font-size: 13px;
font-weight: 600;
@@ -23,12 +23,190 @@
text-align: right;
}
.red-kriticno td { background: rgba(220,38,38,0.04); }
.popis-suma {
display: flex;
gap: 24px;
flex-wrap: wrap;
font-size: 13px;
}
.popis-suma-stavka { display: flex; flex-direction: column; gap: 2px; }
.popis-suma-labela { color: var(--tekst-slabi); font-size: 12px; }
.popis-suma-vrednost { font-weight: 600; font-family: monospace; font-size: 14px; }
.boja-manjak { color: #dc2626; }
.boja-visak { color: #22c55e; }
/* ── MOBILNI (≤768px): tabela → kartice ──────────────────── */
@media (max-width: 768px) {
/* zaglavlje forme: Tip i Napomena u svoj red, jednake pune širine */
.popis-polje { width: 100%; }
#tip-popisa, #napomena-polje { width: 100% !important; }
#tip-popisa { padding-right: 28px; }
/* kartica prikazuje SVE kolone kao desktop; samo zbirni red (tfoot)
se sakriva — ukupno je u sumarnim karticama ispod */
#popis-tabela tfoot { display: none !important; }
/* svaki red postaje kartica, svaka ćelija je labela:vrednost */
#popis-tabela, #popis-tabela tbody, #popis-tabela tr, #popis-tabela td { display: block; width: auto; }
#popis-tabela thead { display: none; }
#popis-tabela tr {
border: 0.5px solid var(--ivica);
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 10px;
}
#popis-tabela td {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: right !important;
padding: 5px 0 !important;
border: none !important;
font-size: 14px;
}
#popis-tabela td::before {
content: attr(data-label);
font-weight: 500;
color: var(--tekst-sporedni);
text-align: left;
white-space: nowrap;
}
/* prva ćelija (naziv) je naslov kartice */
#popis-tabela td.kart-naslov {
justify-content: flex-start;
text-align: left !important;
font-size: 15px;
font-weight: 600;
border-bottom: 0.5px solid var(--ivica) !important;
padding-bottom: 9px !important;
margin-bottom: 5px;
}
#popis-tabela td.kart-naslov::before { content: none; }
/* „Stvarno kol." input — bez spiner strelica (Firefox seče broj) */
.popis-input {
width: 130px;
-moz-appearance: textfield;
appearance: textfield;
}
.popis-input::-webkit-outer-spin-button,
.popis-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
}
/* ── ŠTAMPA: A4 landscape, fiksni layout, NE responsive ───────
Print poništava mobilne kartice (!important) — i sa telefona
se štampa A4 tabela, ne prilagođava se ekranu. */
@media print {
@page { size: A4 landscape; margin: 10mm 8mm; }
/* ── resetuj pozadinu i boje (dark tema + glass efekti) ── */
html, body, .raspored, .glavni-sadrzaj, .sadrzaj, main, .kolona,
.kartica, .kartica div, .kartica p, .kartica span, .kartica label {
background: #fff !important;
color: #111 !important;
text-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
html { background: #fff !important; }
body { min-width: 780px; font-size: 12px; }
body::before, body::after { display: none !important; }
.raspored { position: static !important; z-index: auto !important; }
.glavni-sadrzaj { margin-left: 0 !important; }
.sadrzaj { padding: 0 !important; max-width: none !important; }
/* sakrij hrom */
.sidebar, .sidebar-overlay, .topbar, #hamburger,
.btn-primarno, .btn-sekundarno, .nazad-link,
.poruka-uspeh, .poruka-greska, .flash-toast,
#potvrda-modal, .dugme-stampa, .popis-suma { display: none !important; }
/* sakrij info karticu (uputstvo) */
.popis-info-kartica { display: none !important; }
/* sakrij okvire forme koji nisu tabela */
.popis-forma-zaglavlje,
.popis-forma-sumarno,
.popis-forma-dugme { display: none !important; }
/* ništa ne sme da seče/skroluje */
* { overflow: visible !important; }
/* zaglavlje štampe — naziv firme, datum, tip popisa */
.stampa-zaglavlje { display: block !important; }
/* vrati tabelu iz kartica (mobilni) nazad u pravu tabelu */
#popis-tabela {
display: table !important;
width: 100% !important;
border-collapse: collapse;
font-size: 10px;
}
#popis-tabela thead { display: table-header-group !important; }
#popis-tabela tbody { display: table-row-group !important; }
#popis-tabela tfoot { display: table-footer-group !important; }
#popis-tabela tr {
display: table-row !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
break-inside: avoid;
}
#popis-tabela th {
display: table-cell !important;
border: 0.5px solid #888 !important;
padding: 4px 6px !important;
font-size: 9px !important;
font-weight: 600 !important;
text-align: center !important;
background: #f0f0f0 !important;
color: #111 !important;
}
#popis-tabela th:first-child { text-align: left !important; }
#popis-tabela td {
display: table-cell !important;
border: 0.5px solid #ccc !important;
padding: 3px 6px !important;
text-align: right !important;
font-size: 10px !important;
color: #111 !important;
}
#popis-tabela td.kart-naslov {
text-align: left !important;
font-size: 10px !important;
font-weight: 500 !important;
}
#popis-tabela td::before, #popis-tabela th::before { content: none !important; }
/* inputi — izgledaju kao običan tekst */
.popis-input {
-moz-appearance: textfield !important;
appearance: textfield !important;
border: none !important;
background: transparent !important;
width: auto !important;
max-width: 60px !important;
padding: 0 !important;
font-size: 10px !important;
color: #111 !important;
text-align: right !important;
}
/* sakrij labele iz zaglavlja forme (Tip, Napomena) */
.popis-polje, form .polje-labela { display: none !important; }
}
</style>
{{end}}
{{define "sadrzaj"}}
<div class="kolona" style="gap:16px;">
<!-- zaglavlje štampe (skriveno na ekranu, vidljivo pri štampi) -->
<div class="stampa-zaglavlje" style="display:none;padding:0;margin-bottom:14px;">
<div style="font-size:16px;font-weight:700;margin-bottom:2px;">{{.NazivFirme}}</div>
<div style="font-size:11px;color:#555;">Popis magacina — {{.TipPopisaNaziv}} &nbsp;|&nbsp; Datum: {{.DatumPopisa}} &nbsp;|&nbsp; Sačinio: {{.KorisnikIme}}</div>
</div>
{{if .Sacuvano}}
<div class="poruka-uspeh poruka-animacija">Popis je uspešno sačuvan. Korekcije su upisane u magacinsku evidenciju.</div>
{{end}}
@@ -37,16 +215,23 @@
{{end}}
<!-- uputstvo -->
<div class="kartica animiraj" style="padding:14px 18px;">
<div class="kartica animiraj popis-info-kartica" style="padding:14px 18px;">
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
<div style="flex:1;min-width:260px;">
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-bottom:4px;">Godišnji popis magacina</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-bottom:4px;">Popis magacina</div>
<div style="font-size:13px;color:var(--tekst-slabi);line-height:1.6;">
Unesite stvarno prebrojano stanje za svaki artikal. Polja sa razlikom biće istaknuta narandžasto.
Samo artikli sa izmenjenom količinom biće korigovani — ostali ostaju nepromenjeni.
</div>
<div style="font-size:12px;color:var(--tekst-slabi);margin-top:6px;">
Datum popisa: <strong style="color:var(--tekst-glavni);">{{.DatumPopisa}}</strong>
&nbsp;|&nbsp; Sačinio: <strong style="color:var(--tekst-glavni);">{{.KorisnikIme}}</strong>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/izvestaji" class="btn-sekundarno" style="white-space:nowrap;">← Izveštaji</a>
<button type="button" onclick="window.open('/izvestaji/popis/stampa','_blank')" class="btn-sekundarno" style="white-space:nowrap;">🖨 Štampaj</button>
</div>
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/izvestaji" class="btn-sekundarno" style="white-space:nowrap;">← Izveštaji</a>
</div>
</div>
@@ -55,12 +240,20 @@
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
<div style="padding:12px 20px;border-bottom:0.5px solid var(--ivica);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div style="padding:12px 20px;border-bottom:0.5px solid var(--ivica);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;" class="popis-forma-zaglavlje">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Artikli</span>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<div>
<div class="popis-polje">
<label class="polje-labela" style="display:inline;margin-right:6px;">Tip:</label>
<select name="tip_popisa" id="tip-popisa" style="width:auto;height:auto;font-size:13px;padding:5px 28px 5px 8px;" onchange="azurirajNapomenu()">
<option value="godišnji"{{if eq .TipPopisa "godišnji"}} selected{{end}}>Godišnji</option>
<option value="vanredni"{{if eq .TipPopisa "vanredni"}} selected{{end}}>Vanredni</option>
<option value="preuzimanje"{{if eq .TipPopisa "preuzimanje"}} selected{{end}}>Preuzimanje dužnosti</option>
</select>
</div>
<div class="popis-polje">
<label class="polje-labela" style="display:inline;margin-right:6px;">Napomena:</label>
<input type="text" name="napomena" value="Godišnji popis" style="width:200px;font-size:13px;padding:5px 8px;">
<input type="text" name="napomena" id="napomena-polje" value="Godišnji popis" style="width:220px;font-size:13px;padding:5px 8px;">
</div>
<button type="submit" class="btn-primarno">Sačuvaj popis</button>
</div>
@@ -72,25 +265,32 @@
</div>
{{else}}
<div style="overflow-x:auto;">
<table class="tabela">
<table class="tabela" id="popis-tabela">
<thead>
<tr>
<th>Artikal</th>
<th>Šifra</th>
<th>Kategorija</th>
<th style="text-align:right;">Knjižno stanje</th>
<th style="text-align:right;">Stvarno stanje</th>
<th style="text-align:right;">Razlika</th>
<th class="kol-sek">Šifra</th>
<th class="kol-sek">Kategorija</th>
<th class="kol-sek" style="text-align:right;">JM</th>
<th class="kol-sek" style="text-align:right;">Nab. cena</th>
<th style="text-align:right;">Knj. kol.</th>
<th class="kol-sek" style="text-align:right;">Knj. vrednost</th>
<th style="text-align:right;">Stvarno kol.</th>
<th style="text-align:right;">Razlika kol.</th>
<th class="kol-sek" style="text-align:right;">Razlika vrednost</th>
</tr>
</thead>
<tbody>
{{range .Artikli}}
<tr class="{{if le .Kolicina .KolicinMin}}red-kriticno{{end}}" id="red-{{.ID}}">
<td style="font-weight:500;">{{.Naziv}}</td>
<td style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td style="font-size:12px;color:var(--tekst-slabi);">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
<td style="text-align:right;font-family:monospace;font-weight:600;" id="knjizno-{{.ID}}">{{.Kolicina}}</td>
<td style="text-align:right;">
<td class="kart-naslov" style="font-weight:500;">{{.Naziv}}</td>
<td class="kol-sek" data-label="Šifra" style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td class="kol-sek" data-label="Kategorija" style="font-size:12px;color:var(--tekst-slabi);">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
<td class="kol-sek" data-label="JM" style="text-align:right;font-size:12px;color:var(--tekst-slabi);">{{if .JedinicaMere}}{{.JedinicaMere}}{{else}}kom{{end}}</td>
<td class="kol-sek" data-label="Nab. cena" style="text-align:right;font-family:monospace;font-size:13px;">{{dinari .NabavnaCena}}</td>
<td data-label="Knj. kol." style="text-align:right;font-family:monospace;font-weight:600;" id="knjizno-{{.ID}}">{{.Kolicina}}</td>
<td class="kol-sek" data-label="Knj. vrednost" style="text-align:right;font-family:monospace;font-size:13px;" id="knjvred-{{.ID}}"></td>
<td data-label="Stvarno kol." style="text-align:right;">
<input type="number"
class="popis-input"
name="kolicina_{{.ID}}"
@@ -98,20 +298,63 @@
value="{{.Kolicina}}"
min="0"
data-knjizno="{{.Kolicina}}"
data-cena="{{printf "%.4f" .NabavnaCena}}"
data-pdv="{{printf "%.2f" .PdvStopa}}"
data-id="{{.ID}}"
oninput="azurirajRazliku(this)">
oninput="azurirajRed(this)">
</td>
<td style="text-align:right;">
<td data-label="Razlika kol." style="text-align:right;">
<span class="popis-razlika" id="razlika-{{.ID}}"></span>
</td>
<td class="kol-sek" data-label="Razlika vrednost" style="text-align:right;">
<span class="popis-vrednost-razlika" id="vrazlika-{{.ID}}"></span>
</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr style="font-weight:600;background:var(--pozadina);">
<td colspan="5" style="text-align:right;font-size:12px;color:var(--tekst-slabi);">UKUPNO:</td>
<td style="text-align:right;font-family:monospace;" id="suma-knjizno-kol"></td>
<td style="text-align:right;font-family:monospace;" id="suma-knjizno-vred"></td>
<td style="text-align:right;font-family:monospace;" id="suma-stvarno-kol"></td>
<td style="text-align:right;font-family:monospace;" id="suma-razlika-kol"></td>
<td style="text-align:right;font-family:monospace;" id="suma-razlika-vred"></td>
</tr>
</tfoot>
</table>
</div>
<!-- sumarni pregled manjkova/viškova -->
<div class="popis-forma-sumarno" style="padding:14px 20px;border-top:0.5px solid var(--ivica);">
<div class="popis-suma">
<div class="popis-suma-stavka">
<span class="popis-suma-labela">Manjkovi (kom)</span>
<span class="popis-suma-vrednost boja-manjak" id="sum-manjak-kol">0</span>
</div>
<div class="popis-suma-stavka">
<span class="popis-suma-labela">Manjkovi (vrednost)</span>
<span class="popis-suma-vrednost boja-manjak" id="sum-manjak-vred">0,00</span>
</div>
<div class="popis-suma-stavka">
<span class="popis-suma-labela">Viškovi (kom)</span>
<span class="popis-suma-vrednost boja-visak" id="sum-visak-kol">0</span>
</div>
<div class="popis-suma-stavka">
<span class="popis-suma-labela">Viškovi (vrednost)</span>
<span class="popis-suma-vrednost boja-visak" id="sum-visak-vred">0,00</span>
</div>
{{if index .Moduli "pdv"}}
<div class="popis-suma-stavka" style="border-left:0.5px solid var(--ivica);padding-left:24px;">
<span class="popis-suma-labela">Dugovani PDV na manjak</span>
<span class="popis-suma-vrednost boja-manjak" id="sum-pdv-manjak" title="Manjak delova se smatra sopstvenom potrošnjom — PDV se duguje na nabavnu vrednost (normativ rashoda ≈ 0).">0,00</span>
</div>
{{end}}
</div>
</div>
<!-- dugme na dnu -->
<div style="padding:14px 20px;border-top:0.5px solid var(--ivica);display:flex;justify-content:flex-end;">
<div class="popis-forma-dugme" style="padding:14px 20px;border-top:0.5px solid var(--ivica);display:flex;justify-content:flex-end;">
<button type="submit" class="btn-primarno">Sačuvaj popis</button>
</div>
{{end}}
@@ -121,33 +364,109 @@
</div>
<script>
function azurirajRazliku(input) {
var id = input.dataset.id;
var napomePoTipu = {
"godišnji": "Godišnji popis",
"vanredni": "Vanredni popis",
"preuzimanje": "Popis pri preuzimanju dužnosti"
};
function azurirajNapomenu() {
var tip = document.getElementById('tip-popisa').value;
document.getElementById('napomena-polje').value = napomePoTipu[tip] || "Godišnji popis";
}
function fmt(n) {
return n.toLocaleString('sr-RS', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function azurirajRed(input) {
var id = input.dataset.id;
var knjizno = parseInt(input.dataset.knjizno, 10);
var cena = parseFloat(input.dataset.cena) || 0;
var stvarno = parseInt(input.value, 10);
var span = document.getElementById('razlika-' + id);
var spanKol = document.getElementById('razlika-' + id);
var spanVred = document.getElementById('vrazlika-' + id);
if (isNaN(stvarno)) {
span.textContent = '—';
span.style.color = '';
spanKol.textContent = '—'; spanVred.textContent = '—';
spanKol.style.color = ''; spanVred.style.color = '';
input.classList.remove('izmenjeno');
return;
}
var razlika = stvarno - knjizno;
if (razlika === 0) {
span.textContent = '—';
span.style.color = '';
input.classList.remove('izmenjeno');
} else if (razlika > 0) {
span.textContent = '+' + razlika;
span.style.color = '#22c55e';
input.classList.add('izmenjeno');
} else {
span.textContent = razlika;
span.style.color = '#dc2626';
input.classList.add('izmenjeno');
var razlika = stvarno - knjizno;
if (razlika === 0) {
spanKol.textContent = '—'; spanVred.textContent = '—';
spanKol.style.color = ''; spanVred.style.color = '';
input.classList.remove('izmenjeno');
} else {
var boja = razlika > 0 ? '#22c55e' : '#dc2626';
spanKol.textContent = (razlika > 0 ? '+' : '') + razlika;
spanVred.textContent = (razlika > 0 ? '+' : '') + fmt(razlika * cena);
spanKol.style.color = boja; spanVred.style.color = boja;
input.classList.add('izmenjeno');
}
}
azurirajSumu();
}
function azurirajSumu() {
var inputs = document.querySelectorAll('.popis-input');
var knjiznoKol = 0, knjiznoVred = 0, stvarnoKol = 0;
var manjKol = 0, manjVred = 0, visakKol = 0, visakVred = 0;
var pdvManjak = 0;
inputs.forEach(function(inp) {
var kn = parseInt(inp.dataset.knjizno, 10) || 0;
var cena = parseFloat(inp.dataset.cena) || 0;
var pdv = parseFloat(inp.dataset.pdv) || 0;
var st = parseInt(inp.value, 10);
if (isNaN(st)) st = kn;
knjiznoKol += kn;
knjiznoVred += kn * cena;
stvarnoKol += st;
var r = st - kn;
if (r < 0) {
var manjVrArt = Math.abs(r) * cena;
manjKol += Math.abs(r);
manjVred += manjVrArt;
// manjak delova = sopstvena potrošnja → PDV na nabavnu vrednost (normativ ≈ 0)
pdvManjak += manjVrArt * pdv / 100;
}
if (r > 0) { visakKol += r; visakVred += r * cena; }
});
var razlikaKol = stvarnoKol - knjiznoKol;
var razlikaVred = visakVred - manjVred;
var el = function(id) { return document.getElementById(id); };
if (el('suma-knjizno-kol')) el('suma-knjizno-kol').textContent = knjiznoKol;
if (el('suma-knjizno-vred')) el('suma-knjizno-vred').textContent = fmt(knjiznoVred);
if (el('suma-stvarno-kol')) el('suma-stvarno-kol').textContent = stvarnoKol;
if (el('suma-razlika-kol')) {
el('suma-razlika-kol').textContent = (razlikaKol >= 0 ? '+' : '') + razlikaKol;
el('suma-razlika-kol').style.color = razlikaKol === 0 ? '' : (razlikaKol > 0 ? '#22c55e' : '#dc2626');
}
if (el('suma-razlika-vred')) {
el('suma-razlika-vred').textContent = (razlikaVred >= 0 ? '+' : '') + fmt(razlikaVred);
el('suma-razlika-vred').style.color = razlikaVred === 0 ? '' : (razlikaVred > 0 ? '#22c55e' : '#dc2626');
}
if (el('sum-manjak-kol')) el('sum-manjak-kol').textContent = manjKol;
if (el('sum-manjak-vred')) el('sum-manjak-vred').textContent = fmt(manjVred);
if (el('sum-visak-kol')) el('sum-visak-kol').textContent = visakKol;
if (el('sum-visak-vred')) el('sum-visak-vred').textContent = fmt(visakVred);
if (el('sum-pdv-manjak')) el('sum-pdv-manjak').textContent = fmt(pdvManjak);
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.popis-input').forEach(function(inp) {
var id = inp.dataset.id;
var kn = parseInt(inp.dataset.knjizno, 10) || 0;
var cena = parseFloat(inp.dataset.cena) || 0;
var el = document.getElementById('knjvred-' + id);
if (el) el.textContent = fmt(kn * cena);
});
azurirajSumu();
});
</script>
{{end}}
+165
View File
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Popis — {{.NazivFirme}}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
font-size: 12px;
color: #111;
background: #fff;
min-width: 780px;
}
.strana { max-width: 780px; margin: 0 auto; padding: 24px 32px; }
.zaglavlje { margin-bottom: 16px; }
.firma-naziv { font-size: 18px; font-weight: 700; }
.dokument-naslov { font-size: 14px; font-weight: 600; margin-top: 4px; }
.dokument-meta { font-size: 11px; color: #555; margin-top: 4px; line-height: 1.6; }
.tabela { width: 100%; border-collapse: collapse; margin-top: 12px; }
.tabela th {
font-size: 9px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.3px; color: #555; padding: 5px 6px;
border-bottom: 1.5px solid #888; border-top: 1px solid #ccc;
text-align: center; background: #f7f7f7;
}
.tabela th:first-child { text-align: left; }
.tabela td {
padding: 4px 6px; border-bottom: 0.5px solid #ddd;
font-size: 10px; text-align: right;
}
.tabela td:first-child { text-align: left; font-weight: 500; }
.tabela td.slabo { color: #777; font-size: 9px; font-family: monospace; }
.tabela td.manjak { color: #b91c1c; }
.tabela td.visak { color: #15803d; }
tfoot td {
font-weight: 700; font-size: 10px;
border-top: 1.5px solid #888; border-bottom: 1.5px solid #888;
background: #f7f7f7;
}
.sumarno { margin-top: 16px; padding-top: 10px; border-top: 1px solid #ccc; }
.sumarno-stavke { display: flex; gap: 32px; font-size: 11px; flex-wrap: wrap; }
.sumarno-stavka { display: flex; flex-direction: column; gap: 1px; }
.sumarno-labela { color: #777; font-size: 9px; }
.sumarno-manjak { color: #b91c1c; }
.sumarno-visak { color: #15803d; }
.potpis { margin-top: 28px; display: flex; gap: 60px; }
.potpis-stavka { flex: 1; }
.potpis-linija { border-top: 1px solid #888; margin-top: 28px; padding-top: 3px; font-size: 10px; color: #777; }
@media print {
body { font-size: 11px; }
.strana { padding: 20px 28px; max-width: 780px; }
@page { size: A4 landscape; margin: 8mm 10mm; }
.tabela tr { break-inside: avoid; }
.tabela thead { display: table-header-group; }
.dugme-stampa { display: none !important; }
}
.dugme-stampa {
position: fixed; bottom: 24px; right: 24px;
background: #111; color: #fff; border: none;
padding: 10px 20px; border-radius: 8px;
font-size: 13px; font-weight: 500; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.dugme-stampa:hover { background: #333; }
</style>
</head>
<body>
<div class="strana">
<div class="zaglavlje">
<div class="firma-naziv">{{.NazivFirme}}</div>
<div class="dokument-naslov">Popis magacina</div>
<div class="dokument-meta">
{{.TipPopisaNaziv}} &nbsp;|&nbsp; Datum: {{.DatumPopisa}} &nbsp;|&nbsp; Sačinio: {{.KorisnikIme}}
</div>
</div>
<table class="tabela">
<thead>
<tr>
<th>Artikal</th>
<th>Šifra</th>
<th>Kategorija</th>
<th>JM</th>
<th>Nab. cena</th>
<th>Knj. kol.</th>
<th>Knj. vrednost</th>
<th>Stvarno kol.</th>
<th>Razlika kol.</th>
<th>Razlika vred.</th>
</tr>
</thead>
<tbody>
{{range .Redovi}}
<tr>
<td>{{.Naziv}}</td>
<td class="slabo">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td class="slabo">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
<td>{{if .JedinicaMere}}{{.JedinicaMere}}{{else}}kom{{end}}</td>
<td>{{.NabavnaCenaStr}}</td>
<td>{{.Kolicina}}</td>
<td>{{.KnjiznaVrednostStr}}</td>
<td>{{.StvarnaKolicina}}</td>
<td class="{{if gt .Razlika 0}}visak{{else if lt .Razlika 0}}manjak{{end}}">{{.RazlikaStr}}</td>
<td class="{{if gt .RazlikaVrednost 0.0}}visak{{else if lt .RazlikaVrednost 0.0}}manjak{{end}}">{{.RazlikaVrednostStr}}</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr>
<td colspan="5" style="text-align:right;">UKUPNO:</td>
<td>{{.UkupnoKnjiznoKol}}</td>
<td>{{.UkupnoKnjiznoVredStr}}</td>
<td>{{.UkupnoStvarnoKol}}</td>
<td>{{.UkupnoRazlikaKolStr}}</td>
<td>{{.UkupnoRazlikaVredStr}}</td>
</tr>
</tfoot>
</table>
<div class="sumarno">
<div class="sumarno-stavke">
<div class="sumarno-stavka">
<span class="sumarno-labela">Manjkovi (kom)</span>
<span class="sumarno-manjak">{{.ManjakKol}}</span>
</div>
<div class="sumarno-stavka">
<span class="sumarno-labela">Manjkovi (vrednost)</span>
<span class="sumarno-manjak">{{.ManjakVredStr}}</span>
</div>
<div class="sumarno-stavka">
<span class="sumarno-labela">Viškovi (kom)</span>
<span class="sumarno-visak">{{.VisakKol}}</span>
</div>
<div class="sumarno-stavka">
<span class="sumarno-labela">Viškovi (vrednost)</span>
<span class="sumarno-visak">{{.VisakVredStr}}</span>
</div>
</div>
</div>
<div class="potpis">
<div class="potpis-stavka">
<div class="potpis-linija">Potpis lica koje je izvršilo popis</div>
</div>
<div class="potpis-stavka">
<div class="potpis-linija">Potpis odgovornog lica</div>
</div>
</div>
</div>
<button class="dugme-stampa" onclick="window.print()">Štampaj</button>
<script>window.onload = function() { setTimeout(function() { window.print(); }, 300); }</script>
</body>
</html>
@@ -66,6 +66,10 @@
<span class="bedz" style="background:rgba(168,85,247,0.12);color:#a855f7;">Povraćaj</span>
{{else if eq .TipPromene "korekcija"}}
<span class="bedz" style="background:rgba(156,163,175,0.12);color:#9ca3af;">Korekcija</span>
{{else if eq .TipPromene "manjak_popis"}}
<span class="bedz" style="background:rgba(220,38,38,0.12);color:#dc2626;">Manjak (popis)</span>
{{else if eq .TipPromene "visak_popis"}}
<span class="bedz" style="background:rgba(34,197,94,0.12);color:#22c55e;">Višak (popis)</span>
{{else}}
<span class="bedz">{{.TipPromene}}</span>
{{end}}