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:
+10
-7
@@ -301,12 +301,14 @@ func main() {
|
|||||||
r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr)
|
r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr)
|
||||||
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("/magacin/kartica/{id}", h.MagacinskaKartica)
|
r.Get("/magacin/kartica/{id}", h.MagacinskaKartica)
|
||||||
r.Get("/magacin/novi", h.NoviArtikal)
|
r.Get("/magacin/novi", h.NoviArtikal)
|
||||||
r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal)
|
r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal)
|
||||||
|
r.Get("/magacin/sledeca-sifra", h.PredlogSifre)
|
||||||
r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
|
r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
|
||||||
r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla)
|
r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla)
|
||||||
r.With(doz("artikal.obrisi")).Get("/magacin/obrisi/{id}", h.ObrisiArtikal)
|
r.With(doz("artikal.obrisi")).Get("/magacin/obrisi/{id}", h.ObrisiArtikal)
|
||||||
|
r.With(doz("artikal.obrisi")).Get("/magacin/vrati/{id}", h.VratiArtikal)
|
||||||
r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal)
|
r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal)
|
||||||
r.With(doz("artikal.izmeni")).Post("/magacin/promeni-cenu/{id}", h.PromeniCenuArtikla)
|
r.With(doz("artikal.izmeni")).Post("/magacin/promeni-cenu/{id}", h.PromeniCenuArtikla)
|
||||||
r.With(doz("artikal.izmeni")).Get("/nivelacije", h.Nivelacije)
|
r.With(doz("artikal.izmeni")).Get("/nivelacije", h.Nivelacije)
|
||||||
@@ -338,16 +340,17 @@ func main() {
|
|||||||
r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
|
r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
|
||||||
r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog)
|
r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog)
|
||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga)
|
||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/stampa", h.StampaServisa)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/stampa", h.StampaServisa)
|
||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
|
||||||
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/predracun", h.StampaPredracuna)
|
||||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus)
|
r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus)
|
||||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi", h.DodajDeloNalogu)
|
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi", h.DodajDeloNalogu)
|
||||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga)
|
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga)
|
||||||
r.Get("/izvestaji", h.Izvestaji)
|
r.Get("/izvestaji", h.Izvestaji)
|
||||||
r.Get("/izvestaji/prometni-list", h.PrometniListMagacina)
|
r.Get("/izvestaji/prometni-list", h.PrometniListMagacina)
|
||||||
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
|
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
|
||||||
r.Get("/izvestaji/popis", h.Popis)
|
r.Get("/izvestaji/popis", h.Popis)
|
||||||
r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis)
|
r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis)
|
||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
|
||||||
r.Get("/prodaja/nova", h.NovaProdaja)
|
r.Get("/prodaja/nova", h.NovaProdaja)
|
||||||
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
|
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrArtikalUUpotrebi se vraća kad se artikal ne može obrisati jer postoji u prometu
|
||||||
|
// (prodaja, nabavka, magacinske promene ili servisni nalozi). Tada se artikal arhivira.
|
||||||
|
var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi")
|
||||||
|
|
||||||
// ArtikalRepository definiše operacije nad artiklima
|
// ArtikalRepository definiše operacije nad artiklima
|
||||||
type ArtikalRepository interface {
|
type ArtikalRepository interface {
|
||||||
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
|
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
|
||||||
@@ -18,8 +23,12 @@ type ArtikalRepository interface {
|
|||||||
AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error
|
AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error
|
||||||
PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error
|
PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error
|
||||||
Obrisi(ctx context.Context, id int64) error
|
Obrisi(ctx context.Context, id int64) error
|
||||||
// SledecaSifra vraća predlog sledeće auto-šifre (npr. ART-00042)
|
// Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju)
|
||||||
SledecaSifra(ctx context.Context) (string, error)
|
Arhiviraj(ctx context.Context, id int64) error
|
||||||
|
// Vrati poništava arhiviranje i vraća artikal u aktivnu listu
|
||||||
|
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 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(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error
|
||||||
}
|
}
|
||||||
@@ -79,6 +88,7 @@ type ArtikalFilter struct {
|
|||||||
Pretraga string
|
Pretraga string
|
||||||
KategorijaID *int64
|
KategorijaID *int64
|
||||||
SamoKriticni bool
|
SamoKriticni bool
|
||||||
|
Arhivirani bool // true → vrati samo arhivirane; false (podrazumevano) → samo aktivne
|
||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-24
@@ -3,10 +3,15 @@ package sqlite
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"ntech/internal/db"
|
"ntech/internal/db"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
|
|
||||||
|
mosqlite "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ArtikalRepo je SQLite implementacija ArtikalRepository interfejsa
|
// ArtikalRepo je SQLite implementacija ArtikalRepository interfejsa
|
||||||
@@ -23,9 +28,9 @@ func NoviArtikalRepo(db *sql.DB) *ArtikalRepo {
|
|||||||
func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]model.ArtikalSaKategorijom, error) {
|
func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]model.ArtikalSaKategorijom, error) {
|
||||||
upit := `
|
upit := `
|
||||||
SELECT
|
SELECT
|
||||||
a.id, a.kategorija_id, a.sifra, a.barkod, a.naziv, a.opis,
|
a.id, a.kategorija_id, a.sifra, a.barkod, a.naziv, a.opis, a.tip, a.jedinica_mere,
|
||||||
a.kolicina, a.kolicina_min, a.lokacija,
|
a.kolicina, a.kolicina_min, a.lokacija,
|
||||||
a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.marza, a.napomena, a.datum_unosa,
|
a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.marza, a.napomena, a.datum_unosa, a.arhiviran,
|
||||||
COALESCE(k.naziv, '') as kategorija_naziv, k.marza as kategorija_marza
|
COALESCE(k.naziv, '') as kategorija_naziv, k.marza as kategorija_marza
|
||||||
FROM artikli a
|
FROM artikli a
|
||||||
LEFT JOIN kategorije k ON a.kategorija_id = k.id
|
LEFT JOIN kategorije k ON a.kategorija_id = k.id
|
||||||
@@ -45,7 +50,14 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
if filter.SamoKriticni {
|
if filter.SamoKriticni {
|
||||||
upit += " AND a.kolicina <= a.kolicina_min"
|
upit += " AND (a.tip = 'proizvod' OR a.tip = '') AND a.kolicina <= a.kolicina_min"
|
||||||
|
}
|
||||||
|
|
||||||
|
// podrazumevano vraćamo samo aktivne; arhivirani se prikazuju samo na izričit zahtev
|
||||||
|
if filter.Arhivirani {
|
||||||
|
upit += " AND a.arhiviran = 1"
|
||||||
|
} else {
|
||||||
|
upit += " AND a.arhiviran = 0"
|
||||||
}
|
}
|
||||||
|
|
||||||
upit += " ORDER BY a.naziv ASC"
|
upit += " ORDER BY a.naziv ASC"
|
||||||
@@ -71,16 +83,18 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
|
|||||||
var kategorijaID sql.NullInt64
|
var kategorijaID sql.NullInt64
|
||||||
var sifra, barkod sql.NullString
|
var sifra, barkod sql.NullString
|
||||||
var marza, katMarza sql.NullFloat64
|
var marza, katMarza sql.NullFloat64
|
||||||
|
var arhiviran int
|
||||||
|
|
||||||
err := redovi.Scan(
|
err := redovi.Scan(
|
||||||
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis,
|
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere,
|
||||||
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
|
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
|
||||||
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa,
|
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
|
||||||
&a.KategorijaNaziv, &katMarza,
|
&a.KategorijaNaziv, &katMarza,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: scan: %w", err)
|
return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: scan: %w", err)
|
||||||
}
|
}
|
||||||
|
a.Arhiviran = arhiviran == 1
|
||||||
|
|
||||||
if kategorijaID.Valid {
|
if kategorijaID.Valid {
|
||||||
a.KategorijaID = &kategorijaID.Int64
|
a.KategorijaID = &kategorijaID.Int64
|
||||||
@@ -98,7 +112,8 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
|
|||||||
a.KategorijaMarza = &katMarza.Float64
|
a.KategorijaMarza = &katMarza.Float64
|
||||||
}
|
}
|
||||||
|
|
||||||
a.KriticnaZaliha = a.Kolicina <= a.KolicinMin
|
// kritična zaliha važi samo za proizvode (usluge/troškovi nemaju lager)
|
||||||
|
a.KriticnaZaliha = a.PratiLager() && a.Kolicina <= a.KolicinMin
|
||||||
|
|
||||||
rezultat = append(rezultat, a)
|
rezultat = append(rezultat, a)
|
||||||
}
|
}
|
||||||
@@ -112,18 +127,20 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
|
|||||||
var kategorijaID sql.NullInt64
|
var kategorijaID sql.NullInt64
|
||||||
var sifra, barkod sql.NullString
|
var sifra, barkod sql.NullString
|
||||||
var marza sql.NullFloat64
|
var marza sql.NullFloat64
|
||||||
|
var arhiviran int
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, kategorija_id, sifra, barkod, naziv, opis, kolicina, kolicina_min,
|
SELECT id, kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min,
|
||||||
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa
|
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa, arhiviran
|
||||||
FROM artikli WHERE id = ?`, id).Scan(
|
FROM artikli WHERE id = ?`, id).Scan(
|
||||||
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis,
|
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere,
|
||||||
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
|
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
|
||||||
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa,
|
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
|
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
|
||||||
}
|
}
|
||||||
|
a.Arhiviran = arhiviran == 1
|
||||||
|
|
||||||
if kategorijaID.Valid {
|
if kategorijaID.Valid {
|
||||||
a.KategorijaID = &kategorijaID.Int64
|
a.KategorijaID = &kategorijaID.Int64
|
||||||
@@ -153,10 +170,10 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
|
|||||||
|
|
||||||
rezultat, err := r.db.ExecContext(ctx, `
|
rezultat, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO artikli
|
INSERT INTO artikli
|
||||||
(kategorija_id, sifra, barkod, naziv, opis, kolicina, kolicina_min, lokacija,
|
(kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, lokacija,
|
||||||
nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena)
|
nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin,
|
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, a.Kolicina, a.KolicinMin,
|
||||||
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena,
|
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,12 +200,12 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
|
|||||||
|
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE artikli SET
|
UPDATE artikli SET
|
||||||
kategorija_id = ?, sifra = ?, barkod = ?, naziv = ?, opis = ?, kolicina = ?,
|
kategorija_id = ?, sifra = ?, barkod = ?, naziv = ?, opis = ?, tip = ?, jedinica_mere = ?,
|
||||||
kolicina_min = ?, lokacija = ?,
|
kolicina = ?, kolicina_min = ?, lokacija = ?,
|
||||||
nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, marza = ?, napomena = ?
|
nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, marza = ?, napomena = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Kolicina,
|
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere,
|
||||||
a.KolicinMin, a.Lokacija,
|
a.Kolicina, a.KolicinMin, a.Lokacija,
|
||||||
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, a.ID,
|
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, a.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -198,14 +215,43 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SledecaSifra vraća predlog sledeće auto-šifre u formatu ART-XXXXX
|
// SledecaSifra vraća predlog sledeće auto-šifre u formatu PREFIKS-NNNN.
|
||||||
func (r *ArtikalRepo) SledecaSifra(ctx context.Context) (string, error) {
|
// Prefiks je kôd kategorije (npr. KOMP) ili "ART" ako kategorija nema kôd ili nije zadata.
|
||||||
var n int64
|
// Brojač je najveći postojeći broj za taj prefiks + 1 (otporno na brisanje artikala).
|
||||||
err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM artikli").Scan(&n)
|
func (r *ArtikalRepo) SledecaSifra(ctx context.Context, kategorijaID *int64) (string, error) {
|
||||||
|
prefiks := "ART"
|
||||||
|
if kategorijaID != nil {
|
||||||
|
var kod sql.NullString
|
||||||
|
if err := r.db.QueryRowContext(ctx,
|
||||||
|
"SELECT kod FROM kategorije WHERE id = ?", *kategorijaID).Scan(&kod); err == nil {
|
||||||
|
if kod.Valid && kod.String != "" {
|
||||||
|
prefiks = kod.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redovi, err := r.db.QueryContext(ctx, "SELECT sifra FROM artikli WHERE sifra LIKE ?", prefiks+"-%")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("ntech: ArtikalRepo.SledecaSifra: %w", err)
|
return "", fmt.Errorf("ntech: ArtikalRepo.SledecaSifra: %w", err)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("ART-%05d", n+1), nil
|
defer redovi.Close()
|
||||||
|
|
||||||
|
maxBroj := 0
|
||||||
|
for redovi.Next() {
|
||||||
|
var s string
|
||||||
|
if err := redovi.Scan(&s); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(s, "-")
|
||||||
|
if i < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(s[i+1:]); err == nil && n > maxBroj {
|
||||||
|
maxBroj = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%04d", prefiks, maxBroj+1), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe).
|
// AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe).
|
||||||
@@ -229,16 +275,40 @@ func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategori
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obrisi briše artikal po ID-u
|
// Obrisi briše artikal po ID-u. Ako je artikal u prometu (FK RESTRICT), vraća
|
||||||
|
// db.ErrArtikalUUpotrebi kako bi pozivalac mogao da ga arhivira umesto da ga obriše.
|
||||||
func (r *ArtikalRepo) Obrisi(ctx context.Context, id int64) error {
|
func (r *ArtikalRepo) Obrisi(ctx context.Context, id int64) error {
|
||||||
_, err := r.db.ExecContext(ctx, "DELETE FROM artikli WHERE id = ?", id)
|
_, err := r.db.ExecContext(ctx, "DELETE FROM artikli WHERE id = ?", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// SQLITE_CONSTRAINT_FOREIGNKEY (787) — artikal je referenciran iz druge tabele
|
||||||
|
var sqliteErr *mosqlite.Error
|
||||||
|
if errors.As(err, &sqliteErr) && sqliteErr.Code() == 787 {
|
||||||
|
return db.ErrArtikalUUpotrebi
|
||||||
|
}
|
||||||
return fmt.Errorf("ntech: ArtikalRepo.Obrisi: %w", err)
|
return fmt.Errorf("ntech: ArtikalRepo.Obrisi: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arhiviraj označava artikal kao arhiviran (soft delete za artikle u prometu)
|
||||||
|
func (r *ArtikalRepo) Arhiviraj(ctx context.Context, id int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, "UPDATE artikli SET arhiviran = 1 WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: ArtikalRepo.Arhiviraj: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrati poništava arhiviranje i vraća artikal u aktivnu listu
|
||||||
|
func (r *ArtikalRepo) Vrati(ctx context.Context, id int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, "UPDATE artikli SET arhiviran = 0 WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: ArtikalRepo.Vrati: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// KorigujKolicinu postavlja novu količinu i upisuje korekciju u magacinske_promene
|
// 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 {
|
func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error {
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
@@ -290,7 +360,13 @@ func (r *ArtikalRepo) PrebrojiPoFilteru(ctx context.Context, filter db.ArtikalFi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if filter.SamoKriticni {
|
if filter.SamoKriticni {
|
||||||
upit += " AND a.kolicina <= a.kolicina_min"
|
upit += " AND (a.tip = 'proizvod' OR a.tip = '') AND a.kolicina <= a.kolicina_min"
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Arhivirani {
|
||||||
|
upit += " AND a.arhiviran = 1"
|
||||||
|
} else {
|
||||||
|
upit += " AND a.arhiviran = 0"
|
||||||
}
|
}
|
||||||
|
|
||||||
var broj int
|
var broj int
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func NovaKategorijaRepo(db *sql.DB) *KategorijaRepo {
|
|||||||
|
|
||||||
// Lista vraća sve kategorije
|
// Lista vraća sve kategorije
|
||||||
func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) {
|
func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) {
|
||||||
redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis, marza FROM kategorije ORDER BY naziv ASC")
|
redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis, kod, marza FROM kategorije ORDER BY naziv ASC")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: %w", err)
|
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: %w", err)
|
||||||
}
|
}
|
||||||
@@ -29,14 +29,17 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
|
|||||||
var rezultat []model.Kategorija
|
var rezultat []model.Kategorija
|
||||||
for redovi.Next() {
|
for redovi.Next() {
|
||||||
var k model.Kategorija
|
var k model.Kategorija
|
||||||
var opis sql.NullString
|
var opis, kod sql.NullString
|
||||||
var marza sql.NullFloat64
|
var marza sql.NullFloat64
|
||||||
if err := redovi.Scan(&k.ID, &k.Naziv, &opis, &marza); err != nil {
|
if err := redovi.Scan(&k.ID, &k.Naziv, &opis, &kod, &marza); err != nil {
|
||||||
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: scan: %w", err)
|
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: scan: %w", err)
|
||||||
}
|
}
|
||||||
if opis.Valid {
|
if opis.Valid {
|
||||||
k.Opis = opis.String
|
k.Opis = opis.String
|
||||||
}
|
}
|
||||||
|
if kod.Valid {
|
||||||
|
k.Kod = kod.String
|
||||||
|
}
|
||||||
if marza.Valid {
|
if marza.Valid {
|
||||||
k.Marza = &marza.Float64
|
k.Marza = &marza.Float64
|
||||||
}
|
}
|
||||||
@@ -48,9 +51,13 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
|
|||||||
|
|
||||||
// Kreiraj dodaje novu kategoriju
|
// Kreiraj dodaje novu kategoriju
|
||||||
func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) {
|
func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) {
|
||||||
|
var kod any
|
||||||
|
if k.Kod != "" {
|
||||||
|
kod = k.Kod
|
||||||
|
}
|
||||||
rezultat, err := r.db.ExecContext(ctx,
|
rezultat, err := r.db.ExecContext(ctx,
|
||||||
"INSERT INTO kategorije (naziv, opis, marza) VALUES (?, ?, ?)",
|
"INSERT INTO kategorije (naziv, opis, kod, marza) VALUES (?, ?, ?, ?)",
|
||||||
k.Naziv, k.Opis, k.Marza,
|
k.Naziv, k.Opis, kod, k.Marza,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err)
|
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err)
|
||||||
@@ -67,17 +74,20 @@ func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int6
|
|||||||
// DohvatiID vraća jednu kategoriju po ID-u
|
// DohvatiID vraća jednu kategoriju po ID-u
|
||||||
func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) {
|
func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) {
|
||||||
var k model.Kategorija
|
var k model.Kategorija
|
||||||
var opis sql.NullString
|
var opis, kod sql.NullString
|
||||||
var marza sql.NullFloat64
|
var marza sql.NullFloat64
|
||||||
err := r.db.QueryRowContext(ctx,
|
err := r.db.QueryRowContext(ctx,
|
||||||
"SELECT id, naziv, opis, marza FROM kategorije WHERE id = ?", id).
|
"SELECT id, naziv, opis, kod, marza FROM kategorije WHERE id = ?", id).
|
||||||
Scan(&k.ID, &k.Naziv, &opis, &marza)
|
Scan(&k.ID, &k.Naziv, &opis, &kod, &marza)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err)
|
return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err)
|
||||||
}
|
}
|
||||||
if opis.Valid {
|
if opis.Valid {
|
||||||
k.Opis = opis.String
|
k.Opis = opis.String
|
||||||
}
|
}
|
||||||
|
if kod.Valid {
|
||||||
|
k.Kod = kod.String
|
||||||
|
}
|
||||||
if marza.Valid {
|
if marza.Valid {
|
||||||
k.Marza = &marza.Float64
|
k.Marza = &marza.Float64
|
||||||
}
|
}
|
||||||
@@ -86,9 +96,13 @@ func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Katego
|
|||||||
|
|
||||||
// Izmeni ažurira naziv, opis i maržu postojeće kategorije
|
// Izmeni ažurira naziv, opis i maržu postojeće kategorije
|
||||||
func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error {
|
func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error {
|
||||||
|
var kod any
|
||||||
|
if k.Kod != "" {
|
||||||
|
kod = k.Kod
|
||||||
|
}
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
"UPDATE kategorije SET naziv = ?, opis = ?, marza = ? WHERE id = ?",
|
"UPDATE kategorije SET naziv = ?, opis = ?, kod = ?, marza = ? WHERE id = ?",
|
||||||
k.Naziv, k.Opis, k.Marza, k.ID,
|
k.Naziv, k.Opis, kod, k.Marza, k.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
|
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniN
|
|||||||
func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
|
func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
|
||||||
redovi, err := r.db.QueryContext(ctx, `
|
redovi, err := r.db.QueryContext(ctx, `
|
||||||
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno,
|
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno,
|
||||||
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv
|
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv, a.jedinica_mere
|
||||||
FROM stavke_prodaje sp
|
FROM stavke_prodaje sp
|
||||||
JOIN artikli a ON a.id = sp.artikal_id
|
JOIN artikli a ON a.id = sp.artikal_id
|
||||||
WHERE sp.nalog_id = ?
|
WHERE sp.nalog_id = ?
|
||||||
@@ -140,7 +140,7 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model
|
|||||||
&s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
|
&s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
|
||||||
&s.CenaPoKomadu, &s.Ukupno,
|
&s.CenaPoKomadu, &s.Ukupno,
|
||||||
&s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv,
|
&s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv,
|
||||||
&s.ArtikalNaziv,
|
&s.ArtikalNaziv, &s.JedinicaMere,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err)
|
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err)
|
||||||
@@ -182,25 +182,29 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
|||||||
|
|
||||||
// provera stanja, smanjenje i insert stavki
|
// provera stanja, smanjenje i insert stavki
|
||||||
for _, s := range stavke {
|
for _, s := range stavke {
|
||||||
var naziv string
|
var naziv, tip string
|
||||||
var stanjePre int
|
var stanjePre int
|
||||||
err := tx.QueryRowContext(ctx,
|
err := tx.QueryRowContext(ctx,
|
||||||
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID,
|
"SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID,
|
||||||
).Scan(&naziv, &stanjePre)
|
).Scan(&naziv, &stanjePre, &tip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
|
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stanjePre < s.Kolicina {
|
// usluge i troškovi ne prate lager — ne proveravaju se i ne umanjuju
|
||||||
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
|
pratiLager := tip == model.TipProizvod || tip == ""
|
||||||
}
|
stanjePosle := stanjePre
|
||||||
|
if pratiLager {
|
||||||
stanjePosle := stanjePre - s.Kolicina
|
if stanjePre < s.Kolicina {
|
||||||
_, err = tx.ExecContext(ctx,
|
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
|
||||||
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID,
|
}
|
||||||
)
|
stanjePosle = stanjePre - s.Kolicina
|
||||||
if err != nil {
|
_, err = tx.ExecContext(ctx,
|
||||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
|
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDV računamo iz cene ako nije eksplicitno postavljeno
|
// PDV računamo iz cene ako nije eksplicitno postavljeno
|
||||||
@@ -223,10 +227,13 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
|||||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
|
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
|
// magacinsku promenu beležimo samo za artikle koji prate lager
|
||||||
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
|
if pratiLager {
|
||||||
if err != nil {
|
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
|
||||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err)
|
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,11 +286,17 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
|
|||||||
|
|
||||||
for _, p := range stavke {
|
for _, p := range stavke {
|
||||||
var stanjePre int
|
var stanjePre int
|
||||||
err := tx.QueryRowContext(ctx, "SELECT kolicina FROM artikli WHERE id = ?", p.artikalID).Scan(&stanjePre)
|
var tip string
|
||||||
|
err := tx.QueryRowContext(ctx, "SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID).Scan(&stanjePre, &tip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stanje: %w", err)
|
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stanje: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// usluge i troškovi nemaju stanje na lageru — preskačemo povraćaj
|
||||||
|
if !(tip == model.TipProizvod || tip == "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
stanjePosle := stanjePre + p.kolicina
|
stanjePosle := stanjePre + p.kolicina
|
||||||
_, err = tx.ExecContext(ctx,
|
_, err = tx.ExecContext(ctx,
|
||||||
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
|
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
|
||||||
@@ -352,8 +365,9 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
|
|||||||
redovi.Close()
|
redovi.Close()
|
||||||
|
|
||||||
for _, p := range stavke {
|
for _, p := range stavke {
|
||||||
|
// usluge i troškovi nemaju stanje — vraćamo samo proizvodima
|
||||||
_, err := tx.ExecContext(ctx,
|
_, err := tx.ExecContext(ctx,
|
||||||
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?",
|
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ? AND (tip = 'proizvod' OR tip = '')",
|
||||||
p.kolicina, p.artikalID,
|
p.kolicina, p.artikalID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
|||||||
k := &model.Kategorija{
|
k := &model.Kategorija{
|
||||||
Naziv: naziv,
|
Naziv: naziv,
|
||||||
Opis: r.FormValue("opis"),
|
Opis: r.FormValue("opis"),
|
||||||
|
Kod: normalizujKod(r.FormValue("kod")),
|
||||||
Marza: parsirajMarzu(r.FormValue("marza")),
|
Marza: parsirajMarzu(r.FormValue("marza")),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) {
|
|||||||
ID: id,
|
ID: id,
|
||||||
Naziv: naziv,
|
Naziv: naziv,
|
||||||
Opis: r.FormValue("opis"),
|
Opis: r.FormValue("opis"),
|
||||||
|
Kod: normalizujKod(r.FormValue("kod")),
|
||||||
Marza: parsirajMarzu(r.FormValue("marza")),
|
Marza: parsirajMarzu(r.FormValue("marza")),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +128,19 @@ func parsirajMarzu(s string) *float64 {
|
|||||||
return &v
|
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
|
// ObrisiKategoriju briše kategoriju po ID-u
|
||||||
func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok {
|
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ var saSidebar = []string{
|
|||||||
|
|
||||||
// standalone su šabloni bez base layouta
|
// standalone su šabloni bez base layouta
|
||||||
var standaloneIme = []string{
|
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.
|
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ type PodaciMagacina struct {
|
|||||||
KategorijaIDStr string
|
KategorijaIDStr string
|
||||||
Sacuvano bool
|
Sacuvano bool
|
||||||
Obrisan bool
|
Obrisan bool
|
||||||
|
Arhiviran bool
|
||||||
|
Vracen bool
|
||||||
|
Greska bool
|
||||||
|
PrikazArhivirani bool // true → lista prikazuje arhivirane umesto aktivnih
|
||||||
Premesten bool
|
Premesten bool
|
||||||
StranicaBr int
|
StranicaBr int
|
||||||
UkupnoStranica int
|
UkupnoStranica int
|
||||||
@@ -40,6 +45,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
|||||||
filter := db.ArtikalFilter{
|
filter := db.ArtikalFilter{
|
||||||
Pretraga: r.URL.Query().Get("pretraga"),
|
Pretraga: r.URL.Query().Get("pretraga"),
|
||||||
SamoKriticni: r.URL.Query().Get("kriticni") == "1",
|
SamoKriticni: r.URL.Query().Get("kriticni") == "1",
|
||||||
|
Arhivirani: r.URL.Query().Get("arhivirani") == "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
katIDStr := ""
|
katIDStr := ""
|
||||||
@@ -51,7 +57,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize = 100
|
const pageSize = 50
|
||||||
stranicaBr := 1
|
stranicaBr := 1
|
||||||
if p := r.URL.Query().Get("stranica"); p != "" {
|
if p := r.URL.Query().Get("stranica"); p != "" {
|
||||||
if v, err := strconv.Atoi(p); err == nil && v > 0 {
|
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 {
|
if filter.SamoKriticni {
|
||||||
queryDelići += "&kriticni=1"
|
queryDelići += "&kriticni=1"
|
||||||
}
|
}
|
||||||
|
if filter.Arhivirani {
|
||||||
|
queryDelići += "&arhivirani=1"
|
||||||
|
}
|
||||||
|
|
||||||
stranicaPrev := stranicaBr - 1
|
stranicaPrev := stranicaBr - 1
|
||||||
if stranicaPrev < 1 {
|
if stranicaPrev < 1 {
|
||||||
@@ -114,6 +123,10 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
|||||||
KategorijaIDStr: katIDStr,
|
KategorijaIDStr: katIDStr,
|
||||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||||
Obrisan: r.URL.Query().Get("obrisan") == "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",
|
Premesten: r.URL.Query().Get("premesten") == "1",
|
||||||
StranicaBr: stranicaBr,
|
StranicaBr: stranicaBr,
|
||||||
UkupnoStranica: ukupnoStranica,
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/magacin?obrisan=1", http.StatusSeeOther)
|
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
|
// PodaciMagacinskeKartice su podaci za karticu jednog artikla
|
||||||
type PodaciMagacinskeKartice struct {
|
type PodaciMagacinskeKartice struct {
|
||||||
model.PodaciStranice
|
model.PodaciStranice
|
||||||
Artikal model.Artikal
|
Artikal model.Artikal
|
||||||
Promene []model.MagacinskaPromenaSaDetaljem
|
Promene []model.MagacinskaPromenaSaDetaljem
|
||||||
}
|
}
|
||||||
|
|
||||||
// MagacinskaKartica prikazuje sve promene stanja za jedan artikal
|
// MagacinskaKartica prikazuje sve promene stanja za jedan artikal
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
predlogSifre, err := h.Artikli.SledecaSifra(r.Context())
|
predlogSifre, err := h.Artikli.SledecaSifra(r.Context(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("greška pri generisanju predloga šifre", "err", err)
|
slog.Error("greška pri generisanju predloga šifre", "err", err)
|
||||||
predlogSifre = "ART-00001"
|
predlogSifre = "ART-0001"
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||||
@@ -49,7 +49,7 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||||
PodaciStranice: ps,
|
PodaciStranice: ps,
|
||||||
Kategorije: kategorije,
|
Kategorije: kategorije,
|
||||||
Artikal: model.Artikal{Sifra: predlogSifre},
|
Artikal: model.Artikal{Sifra: predlogSifre, Tip: model.TipProizvod, JedinicaMere: "kom"},
|
||||||
Izmena: false,
|
Izmena: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -94,9 +94,12 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 == "" {
|
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.ID = id
|
||||||
artikal.Sifra = autoSifra
|
artikal.Sifra = autoSifra
|
||||||
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
|
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.Lokacija = r.FormValue("lokacija")
|
||||||
artikal.Napomena = r.FormValue("napomena")
|
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 != "" {
|
if k := r.FormValue("kolicina"); k != "" {
|
||||||
v, err := strconv.Atoi(k)
|
v, err := strconv.Atoi(k)
|
||||||
if err != nil || v < 0 {
|
if err != nil || v < 0 {
|
||||||
@@ -260,6 +279,12 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
|||||||
artikal.KolicinMin = v
|
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 != "" {
|
if c := r.FormValue("nabavna_cena"); c != "" {
|
||||||
v, err := strconv.ParseFloat(c, 64)
|
v, err := strconv.ParseFloat(c, 64)
|
||||||
if err != nil || v < 0 {
|
if err != nil || v < 0 {
|
||||||
@@ -295,6 +320,23 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
|||||||
return artikal, ""
|
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
|
// renderujFormuArtikla renderuje HTML formu za artikal
|
||||||
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
|
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
|
||||||
h.renderujTemplate(w, "magacin_forma", podaci)
|
h.renderujTemplate(w, "magacin_forma", podaci)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type PodaciPodesavanja struct {
|
|||||||
BackupBrojKopija string
|
BackupBrojKopija string
|
||||||
KalkulacijaMarza string
|
KalkulacijaMarza string
|
||||||
ServisGarancijaMeseci string
|
ServisGarancijaMeseci string
|
||||||
|
PredracunRokDana string
|
||||||
LoginPozadina string
|
LoginPozadina string
|
||||||
LoginPozadinaOpacity string
|
LoginPozadinaOpacity string
|
||||||
LoginPozadinaBlurPozadine 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)
|
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"),
|
BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
|
||||||
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
|
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
|
||||||
ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"),
|
ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"),
|
||||||
|
PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"),
|
||||||
}, nil
|
}, 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
|
// PromeniStatus obrađuje POST /servis/{id}/status i menja samo status naloga
|
||||||
func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := parseID(chi.URLParam(r, "id"))
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// Tipovi artikla. Proizvod prati stanje na lageru; usluga i trošak ga ne prate.
|
||||||
|
const (
|
||||||
|
TipProizvod = "proizvod"
|
||||||
|
TipUsluga = "usluga"
|
||||||
|
TipTrosak = "trosak"
|
||||||
|
)
|
||||||
|
|
||||||
// Artikal predstavlja jedan artikal u magacinu
|
// Artikal predstavlja jedan artikal u magacinu
|
||||||
type Artikal struct {
|
type Artikal struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -10,6 +17,8 @@ type Artikal struct {
|
|||||||
Barkod string
|
Barkod string
|
||||||
Naziv string
|
Naziv string
|
||||||
Opis string
|
Opis string
|
||||||
|
Tip string // proizvod | usluga | trosak
|
||||||
|
JedinicaMere string // kom, sat, set, m, l, kg ...
|
||||||
Kolicina int
|
Kolicina int
|
||||||
KolicinMin int
|
KolicinMin int
|
||||||
Lokacija string
|
Lokacija string
|
||||||
@@ -19,6 +28,13 @@ type Artikal struct {
|
|||||||
Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno
|
Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno
|
||||||
Napomena string
|
Napomena string
|
||||||
DatumUnosa time.Time
|
DatumUnosa time.Time
|
||||||
|
Arhiviran bool // artikal u prometu koji je sklonjen iz aktivne liste; istorija ostaje
|
||||||
|
}
|
||||||
|
|
||||||
|
// PratiLager vraća true samo za proizvode (usluge i troškovi nemaju stanje na lageru).
|
||||||
|
// Prazan tip se tretira kao proizvod radi kompatibilnosti sa starim zapisima.
|
||||||
|
func (a Artikal) PratiLager() bool {
|
||||||
|
return a.Tip == TipProizvod || a.Tip == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// CenaBezPdv izračunava prodajnu cenu bez PDV-a
|
// CenaBezPdv izračunava prodajnu cenu bez PDV-a
|
||||||
@@ -36,6 +52,7 @@ type Kategorija struct {
|
|||||||
ID int64
|
ID int64
|
||||||
Naziv string
|
Naziv string
|
||||||
Opis string
|
Opis string
|
||||||
|
Kod string // prefiks za šifru artikla (npr. KOMP -> KOMP-0001)
|
||||||
Marza *float64 // podrazumevana marža (%) za artikle ove kategorije; NULL = nije postavljeno
|
Marza *float64 // podrazumevana marža (%) za artikle ove kategorije; NULL = nije postavljeno
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import "time"
|
|||||||
|
|
||||||
// ProdajniNalog predstavlja zaglavlje jedne prodaje
|
// ProdajniNalog predstavlja zaglavlje jedne prodaje
|
||||||
type ProdajniNalog struct {
|
type ProdajniNalog struct {
|
||||||
ID int64
|
ID int64
|
||||||
KlijentID *int64
|
KlijentID *int64
|
||||||
BrojNaloga string
|
BrojNaloga string
|
||||||
Napomena string
|
Napomena string
|
||||||
Ukupno float64
|
Ukupno float64
|
||||||
NacinPlacanja string
|
NacinPlacanja string
|
||||||
Stornirano bool
|
Stornirano bool
|
||||||
RazlogStorniranja string
|
RazlogStorniranja string
|
||||||
Datum time.Time
|
Datum time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
|
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
|
||||||
@@ -38,4 +38,5 @@ type ProdajniNalogSaDetaljem struct {
|
|||||||
type StavkaProdajeSaArtiklom struct {
|
type StavkaProdajeSaArtiklom struct {
|
||||||
StavkaProdaje
|
StavkaProdaje
|
||||||
ArtikalNaziv string
|
ArtikalNaziv string
|
||||||
|
JedinicaMere string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Arhiviranje artikala: artikal koji je bio u prometu ne može se obrisati (FK RESTRICT),
|
||||||
|
-- pa se umesto brisanja označava kao arhiviran. Arhiviran artikal se ne nudi za nov promet,
|
||||||
|
-- ali ostaje vidljiv u istoriji (prodaja, nabavka, servis) i na svojoj kartici.
|
||||||
|
ALTER TABLE artikli ADD COLUMN arhiviran INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Kôd kategorije služi kao prefiks šifre artikla (npr. "Komponente" -> KOMP -> KOMP-0001).
|
||||||
|
ALTER TABLE kategorije ADD COLUMN kod TEXT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Tip artikla: 'proizvod' (prati lager), 'usluga' i 'trosak' (ne prate lager).
|
||||||
|
ALTER TABLE artikli ADD COLUMN tip TEXT NOT NULL DEFAULT 'proizvod';
|
||||||
|
-- Jedinica mere artikla (kom, sat, set, m, l, kg ...).
|
||||||
|
ALTER TABLE artikli ADD COLUMN jedinica_mere TEXT NOT NULL DEFAULT 'kom';
|
||||||
+21
-6
@@ -515,6 +515,9 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
/* sitna akciona dugmad ne prelamaju tekst — inače „Promeni cenu" padne u 2 reda
|
||||||
|
pa to dugme postane više od ostalih u istom redu */
|
||||||
|
white-space: nowrap;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
.btn-primarno-malo:hover { opacity: 0.85; }
|
.btn-primarno-malo:hover { opacity: 0.85; }
|
||||||
@@ -536,6 +539,10 @@ body {
|
|||||||
.btn-sekundarno:hover { background: var(--pozadina); color: var(--tekst-glavni); }
|
.btn-sekundarno:hover { background: var(--pozadina); color: var(--tekst-glavni); }
|
||||||
|
|
||||||
/* crveno dugme za brisanje u tabelama */
|
/* crveno dugme za brisanje u tabelama */
|
||||||
|
/* naziv artikla u listi koji vodi na karticu */
|
||||||
|
.link-naziv { text-decoration: none; }
|
||||||
|
.link-naziv:hover { color: var(--sb-akcent); text-decoration: underline; }
|
||||||
|
|
||||||
.btn-obrisi-malo {
|
.btn-obrisi-malo {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -548,6 +555,7 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
.btn-obrisi-malo:hover { opacity: 0.8; }
|
.btn-obrisi-malo:hover { opacity: 0.8; }
|
||||||
@@ -1070,6 +1078,16 @@ select {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* gasi animaciju redova pri HTMX pretrazi: hx-on:htmx:before-request dodaje ovu klasu
|
||||||
|
na #magacin-rezultati. Pošto pretraga menja samo innerHTML kontejnera, klasa ostaje
|
||||||
|
na njemu i kroz naredne zamene, pa novoubačeni redovi ne animiraju. Pun reload
|
||||||
|
stranice i klik na paginaciju (bez pretrage) ne dodaju klasu, pa animacija ostaje.
|
||||||
|
ID u selektoru je OBAVEZAN: bez njega specifičnost je jednaka pravilima
|
||||||
|
[data-animacija="..."] niže u fajlu, koja bi onda nadjačala ovo i animacija bi
|
||||||
|
se i dalje primenjivala kad korisnik ima izabran stil animacije. */
|
||||||
|
#magacin-rezultati.bez-anim .tabela tbody tr,
|
||||||
|
#magacin-rezultati.bez-anim .animiraj { animation: none; }
|
||||||
|
|
||||||
/* korisnikova preferencija animacije: body[data-animacija] nadjačava podrazumevano.
|
/* korisnikova preferencija animacije: body[data-animacija] nadjačava podrazumevano.
|
||||||
Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */
|
Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */
|
||||||
[data-animacija="bez"] .animiraj,
|
[data-animacija="bez"] .animiraj,
|
||||||
@@ -1121,12 +1139,9 @@ select {
|
|||||||
.tabela tbody tr:nth-child(20) { animation-delay: 0.80s; }
|
.tabela tbody tr:nth-child(20) { animation-delay: 0.80s; }
|
||||||
|
|
||||||
/* content-visibility: auto – browser preskače render elemenata van viewport-a.
|
/* content-visibility: auto – browser preskače render elemenata van viewport-a.
|
||||||
Ovo rešava problem sa 1000+ redova gde skrolovanje postaje prazno
|
Stranica je ograničena na 50 redova, pa redovi tabele ne koriste ovu optimizaciju:
|
||||||
dok browser ne stigne da iscrta sve. */
|
procenjena visina (contain-intrinsic-size) se nije poklapala sa stvarnom, pa su
|
||||||
.tabela tbody tr {
|
redovi „secali" pri skrolu kad ih browser tek tada izmeri. */
|
||||||
content-visibility: auto;
|
|
||||||
contain-intrinsic-size: 48px;
|
|
||||||
}
|
|
||||||
[class*="-kartice"] > .animiraj,
|
[class*="-kartice"] > .animiraj,
|
||||||
.klijenti-kartice > .animiraj {
|
.klijenti-kartice > .animiraj {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
<input type="text" name="naziv" placeholder="npr. Memorija, Diskovi, Kablovi..."
|
<input type="text" name="naziv" placeholder="npr. Memorija, Diskovi, Kablovi..."
|
||||||
style="width:100%;">
|
style="width:100%;">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Kôd (prefiks šifre)</label>
|
||||||
|
<input type="text" name="kod" placeholder="npr. MEM (daje šifre MEM-0001)"
|
||||||
|
maxlength="10" style="width:100%;text-transform:uppercase;">
|
||||||
|
<div class="pomocni-tekst" style="margin-top:4px;">Samo slova i brojevi. Prazno = šifre kreću sa ART-.</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="polje-labela">Opis</label>
|
<label class="polje-labela">Opis</label>
|
||||||
<input type="text" name="opis" placeholder="Kratak opis kategorije..."
|
<input type="text" name="opis" placeholder="Kratak opis kategorije..."
|
||||||
@@ -76,7 +82,10 @@
|
|||||||
{{range .Kategorije}}
|
{{range .Kategorije}}
|
||||||
<div class="kat-red animiraj" style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:0.5px solid var(--ivica);">
|
<div class="kat-red animiraj" style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:0.5px solid var(--ivica);">
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</div>
|
<div style="font-size:14px;color:var(--tekst-glavni);">
|
||||||
|
{{.Naziv}}
|
||||||
|
{{if .Kod}}<span style="font-size:11px;font-family:monospace;color:var(--tekst-sporedni);background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:4px;padding:1px 6px;margin-left:6px;">{{.Kod}}</span>{{end}}
|
||||||
|
</div>
|
||||||
{{if .Opis}}
|
{{if .Opis}}
|
||||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.Opis}}</div>
|
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.Opis}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -98,6 +107,12 @@
|
|||||||
<input type="text" name="naziv" value="{{.Naziv}}" required
|
<input type="text" name="naziv" value="{{.Naziv}}" required
|
||||||
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Kôd (prefiks šifre)</label>
|
||||||
|
<input type="text" name="kod" value="{{.Kod}}" maxlength="10"
|
||||||
|
placeholder="npr. MEM"
|
||||||
|
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;text-transform:uppercase;">
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="polje-labela">Opis</label>
|
<label class="polje-labela">Opis</label>
|
||||||
<input type="text" name="opis" value="{{.Opis}}"
|
<input type="text" name="opis" value="{{.Opis}}"
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
{{if .Obrisan}}
|
{{if .Obrisan}}
|
||||||
<div class="poruka-uspeh">Artikal je uspešno obrisan.</div>
|
<div class="poruka-uspeh">Artikal je uspešno obrisan.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .Arhiviran}}
|
||||||
|
<div class="poruka-uspeh">Artikal je u prometu pa je arhiviran umesto obrisan. Možete ga pronaći među arhiviranim artiklima.</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Vracen}}
|
||||||
|
<div class="poruka-uspeh">Artikal je vraćen u aktivnu listu.</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Greska}}
|
||||||
|
<div class="poruka-greska">Operacija nije uspela. Pokušajte ponovo.</div>
|
||||||
|
{{end}}
|
||||||
{{if .Premesten}}
|
{{if .Premesten}}
|
||||||
<div class="poruka-uspeh">Artikal je premešten u drugu kategoriju.</div>
|
<div class="poruka-uspeh">Artikal je premešten u drugu kategoriju.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -26,10 +35,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- pretraga i filteri — interaktivna pretraga (hx-trigger) -->
|
<!-- pretraga i filteri — interaktivna pretraga (hx-trigger) -->
|
||||||
<form method="GET" action="/magacin" class="kolona" style="gap:10px;"
|
<form method="GET" action="/magacin" class="kolona" style="gap:10px;" id="magacin-filteri"
|
||||||
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||||
<input type="text" name="pretraga" value="{{.Filter.Pretraga}}"
|
<input type="text" name="pretraga" value="{{.Filter.Pretraga}}"
|
||||||
placeholder="Pretraži artikle..."
|
placeholder="Pretraži po nazivu, šifri, barkodu, lokaciji..."
|
||||||
style="width:100%;"
|
style="width:100%;"
|
||||||
hx-trigger="keyup changed delay:300ms, search"
|
hx-trigger="keyup changed delay:300ms, search"
|
||||||
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||||
@@ -48,6 +57,12 @@
|
|||||||
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||||
Samo kritični
|
Samo kritični
|
||||||
</label>
|
</label>
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;background:var(--kartica);white-space:nowrap;">
|
||||||
|
<input type="checkbox" name="arhivirani" value="1" {{if .PrikazArhivirani}}checked{{end}}
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||||
|
Arhivirani
|
||||||
|
</label>
|
||||||
<button type="submit" class="btn-primarno">
|
<button type="submit" class="btn-primarno">
|
||||||
Traži
|
Traži
|
||||||
</button>
|
</button>
|
||||||
@@ -63,6 +78,7 @@
|
|||||||
<table class="magacin-tabela tabela">
|
<table class="magacin-tabela tabela">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
<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-jak);">Šifra</th>
|
||||||
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Naziv</th>
|
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Naziv</th>
|
||||||
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Kategorija</th>
|
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Kategorija</th>
|
||||||
<th style="padding:12px 16px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-jak);">Količina</th>
|
<th style="padding:12px 16px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-jak);">Količina</th>
|
||||||
@@ -74,14 +90,19 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Artikli}}
|
{{range .Artikli}}
|
||||||
<tr class="animiraj red-tabele">
|
<tr class="animiraj red-tabele">
|
||||||
<td style="padding:12px 16px;font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</td>
|
<td style="padding:12px 16px;font-size:13px;font-family:monospace;white-space:nowrap;">{{if .Sifra}}<a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="color:var(--tekst-sporedni);">{{.Sifra}}</a>{{else}}—{{end}}</td>
|
||||||
|
<td style="padding:12px 16px;font-size:14px;"><a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="color:var(--tekst-glavni);font-weight:500;">{{.Naziv}}</a></td>
|
||||||
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
|
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
|
||||||
{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}
|
{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:12px 16px;text-align:center;">
|
<td style="padding:12px 16px;text-align:center;">
|
||||||
|
{{if .PratiLager}}
|
||||||
<span style="font-size:14px;font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">
|
<span style="font-size:14px;font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">
|
||||||
{{.Kolicina}}
|
{{.Kolicina}}
|
||||||
</span>
|
</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:12px;color:var(--tekst-sporedni);">{{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}}</span>
|
||||||
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{printf "%.0f" .ProdajnaCena}} din</td>
|
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{printf "%.0f" .ProdajnaCena}} din</td>
|
||||||
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
|
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
|
||||||
@@ -89,9 +110,6 @@
|
|||||||
</td>
|
</td>
|
||||||
<td style="padding:12px 16px;text-align:center;">
|
<td style="padding:12px 16px;text-align:center;">
|
||||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
|
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
|
||||||
<a href="/magacin/kartica/{{.ID}}" class="btn-sekundarno-malo">
|
|
||||||
Kartica
|
|
||||||
</a>
|
|
||||||
{{if index $.Dozvole "artikal.izmeni"}}
|
{{if index $.Dozvole "artikal.izmeni"}}
|
||||||
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">
|
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">
|
||||||
Izmeni
|
Izmeni
|
||||||
@@ -102,11 +120,18 @@
|
|||||||
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "tab")}}
|
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "tab")}}
|
||||||
{{end}}{{end}}
|
{{end}}{{end}}
|
||||||
{{if index $.Dozvole "artikal.obrisi"}}
|
{{if index $.Dozvole "artikal.obrisi"}}
|
||||||
|
{{if $.PrikazArhivirani}}
|
||||||
|
<a href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo"
|
||||||
|
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
|
||||||
|
Vrati
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
|
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
|
||||||
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
|
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
|
||||||
Obriši
|
Obriši
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -128,13 +153,15 @@
|
|||||||
<div class="kartica magacin-kartica animiraj">
|
<div class="kartica magacin-kartica animiraj">
|
||||||
<div class="red-izmedju" style="align-items:flex-start;gap:12px;margin-bottom:10px;flex-wrap:wrap;">
|
<div class="red-izmedju" style="align-items:flex-start;gap:12px;margin-bottom:10px;flex-wrap:wrap;">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</div>
|
<a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="display:inline-block;font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</a>
|
||||||
|
{{if .Sifra}}
|
||||||
|
<div style="font-size:12px;font-family:monospace;color:var(--tekst-sporedni);margin-top:2px;">{{.Sifra}}</div>
|
||||||
|
{{end}}
|
||||||
{{if .KategorijaNaziv}}
|
{{if .KategorijaNaziv}}
|
||||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.KategorijaNaziv}}</div>
|
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.KategorijaNaziv}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;">
|
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;">
|
||||||
<a href="/magacin/kartica/{{.ID}}" class="btn-sekundarno-malo">Kartica</a>
|
|
||||||
{{if index $.Dozvole "artikal.izmeni"}}
|
{{if index $.Dozvole "artikal.izmeni"}}
|
||||||
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">Izmeni</a>
|
<a href="/magacin/izmeni/{{.ID}}" class="btn-primarno-malo">Izmeni</a>
|
||||||
{{template "promeniCenuMeni" (dict "ID" .ID "Cena" .ProdajnaCena)}}
|
{{template "promeniCenuMeni" (dict "ID" .ID "Cena" .ProdajnaCena)}}
|
||||||
@@ -143,17 +170,29 @@
|
|||||||
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}}
|
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}}
|
||||||
{{end}}{{end}}
|
{{end}}{{end}}
|
||||||
{{if index $.Dozvole "artikal.obrisi"}}
|
{{if index $.Dozvole "artikal.obrisi"}}
|
||||||
|
{{if $.PrikazArhivirani}}
|
||||||
|
<a href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo"
|
||||||
|
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
|
||||||
|
Vrati
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
|
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
|
||||||
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
|
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
|
||||||
Obriši
|
Obriši
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:10px;">
|
<div style="display:flex;flex-wrap:wrap;gap:10px;">
|
||||||
<div class="pomocni-tekst">
|
<div class="pomocni-tekst">
|
||||||
|
{{if .PratiLager}}
|
||||||
<span style="color:var(--tekst-glavni);font-weight:500;">Količina:</span>
|
<span style="color:var(--tekst-glavni);font-weight:500;">Količina:</span>
|
||||||
<span style="font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">{{.Kolicina}}</span>
|
<span style="font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">{{.Kolicina}} {{.JedinicaMere}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="color:var(--tekst-glavni);font-weight:500;">Tip:</span>
|
||||||
|
<span style="color:var(--tekst-sporedni);">{{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}}</span>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="pomocni-tekst">
|
<div class="pomocni-tekst">
|
||||||
<span style="color:var(--tekst-glavni);font-weight:500;">Cena:</span> {{printf "%.0f" .ProdajnaCena}} din
|
<span style="color:var(--tekst-glavni);font-weight:500;">Cena:</span> {{printf "%.0f" .ProdajnaCena}} din
|
||||||
@@ -190,6 +229,20 @@
|
|||||||
</div><!-- kraj #magacin-rezultati -->
|
</div><!-- kraj #magacin-rezultati -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Gasi stagger animaciju redova pri pretrazi/filtriranju (ali NE pri paginaciji).
|
||||||
|
// hx-on atribut ne radi pouzdano u ovoj htmx verziji, pa koristimo globalni listener:
|
||||||
|
// kada zahtev kreće sa elementa unutar #magacin-filteri, dodamo .bez-anim na kontejner
|
||||||
|
// rezultata. Pošto se menja samo innerHTML kontejnera, klasa ostaje i kroz naredne zamene.
|
||||||
|
document.body.addEventListener('htmx:beforeRequest', function (e) {
|
||||||
|
var elt = e.detail && e.detail.elt;
|
||||||
|
if (elt && elt.closest && elt.closest('#magacin-filteri')) {
|
||||||
|
var rez = document.getElementById('magacin-rezultati');
|
||||||
|
if (rez) rez.classList.add('bez-anim');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* padajući meni za premeštanje artikla — prima dict {ID, Kategorije}; koristi se i u tabeli i u mobilnoj kartici */}}
|
{{/* padajući meni za premeštanje artikla — prima dict {ID, Kategorije}; koristi se i u tabeli i u mobilnoj kartici */}}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
<div>
|
<div>
|
||||||
<label class="polje-labela">Šifra artikla</label>
|
<label class="polje-labela">Šifra artikla</label>
|
||||||
<input type="text" name="sifra" value="{{.Artikal.Sifra}}"
|
<input type="text" name="sifra" id="sifra-input" value="{{.Artikal.Sifra}}"
|
||||||
placeholder="npr. ART-00001"
|
placeholder="npr. KOMP-0001"
|
||||||
style="width:100%;font-family:monospace;">
|
style="width:100%;font-family:monospace;">
|
||||||
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Ako ostaviš prazno, šifra se automatski dodeljuje.</div>
|
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Ako ostaviš prazno, šifra se automatski dodeljuje.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +53,32 @@
|
|||||||
style="width:100%;">
|
style="width:100%;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- tip i jedinica mere -->
|
||||||
|
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Tip artikla</label>
|
||||||
|
<select name="tip" id="tip-artikla" style="width:100%;">
|
||||||
|
<option value="proizvod" {{if eq .Artikal.Tip "proizvod"}}selected{{end}}>Proizvod (prati lager)</option>
|
||||||
|
<option value="usluga" {{if eq .Artikal.Tip "usluga"}}selected{{end}}>Usluga</option>
|
||||||
|
<option value="trosak" {{if eq .Artikal.Tip "trosak"}}selected{{end}}>Trošak</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela">Jedinica mere</label>
|
||||||
|
{{$jm := .Artikal.JedinicaMere}}
|
||||||
|
<select name="jedinica_mere" style="width:100%;">
|
||||||
|
<option value="kom" {{if eq $jm "kom"}}selected{{end}}>kom</option>
|
||||||
|
<option value="sat" {{if eq $jm "sat"}}selected{{end}}>sat</option>
|
||||||
|
<option value="set" {{if eq $jm "set"}}selected{{end}}>set</option>
|
||||||
|
<option value="m" {{if eq $jm "m"}}selected{{end}}>m</option>
|
||||||
|
<option value="m2" {{if eq $jm "m2"}}selected{{end}}>m²</option>
|
||||||
|
<option value="l" {{if eq $jm "l"}}selected{{end}}>l</option>
|
||||||
|
<option value="kg" {{if eq $jm "kg"}}selected{{end}}>kg</option>
|
||||||
|
<option value="pak" {{if eq $jm "pak"}}selected{{end}}>pak</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- kategorija -->
|
<!-- kategorija -->
|
||||||
<div>
|
<div>
|
||||||
<label class="polje-labela">Kategorija</label>
|
<label class="polje-labela">Kategorija</label>
|
||||||
@@ -72,8 +98,8 @@
|
|||||||
style="width:100%;resize:vertical;">{{.Artikal.Opis}}</textarea>
|
style="width:100%;resize:vertical;">{{.Artikal.Opis}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- količina i minimum -->
|
<!-- količina i minimum (samo za proizvode) -->
|
||||||
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
<div class="forma-grid-2" data-lager style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
<div>
|
<div>
|
||||||
<label class="polje-labela">Količina na stanju</label>
|
<label class="polje-labela">Količina na stanju</label>
|
||||||
<input type="number" name="kolicina" value="{{.Artikal.Kolicina}}" min="0" style="width:100%;">
|
<input type="number" name="kolicina" value="{{.Artikal.Kolicina}}" min="0" style="width:100%;">
|
||||||
@@ -102,8 +128,8 @@
|
|||||||
placeholder="prazno = po kategoriji / globalna">
|
placeholder="prazno = po kategoriji / globalna">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- lokacija -->
|
<!-- lokacija (samo za proizvode) -->
|
||||||
<div>
|
<div data-lager>
|
||||||
<label class="polje-labela">Lokacija u magacinu</label>
|
<label class="polje-labela">Lokacija u magacinu</label>
|
||||||
<input type="text" name="lokacija" value="{{.Artikal.Lokacija}}"
|
<input type="text" name="lokacija" value="{{.Artikal.Lokacija}}"
|
||||||
placeholder="npr. Polica A3, Kutija 2..."
|
placeholder="npr. Polica A3, Kutija 2..."
|
||||||
@@ -130,4 +156,34 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// sakrivanje polja lagera za usluge i troškove
|
||||||
|
var tip = document.getElementById('tip-artikla');
|
||||||
|
var lager = document.querySelectorAll('[data-lager]');
|
||||||
|
function azurirajLager() {
|
||||||
|
var prati = tip.value === 'proizvod';
|
||||||
|
lager.forEach(function (el) { el.style.display = prati ? '' : 'none'; });
|
||||||
|
}
|
||||||
|
tip.addEventListener('change', azurirajLager);
|
||||||
|
azurirajLager();
|
||||||
|
|
||||||
|
// auto-predlog šifre pri promeni kategorije (samo za nov artikal i ako šifra nije ručno menjana)
|
||||||
|
var kat = document.querySelector('select[name="kategorija_id"]');
|
||||||
|
var sifra = document.getElementById('sifra-input');
|
||||||
|
var jeIzmena = {{if .Izmena}}true{{else}}false{{end}};
|
||||||
|
if (sifra) {
|
||||||
|
sifra.addEventListener('input', function () { sifra.dataset.rucno = '1'; });
|
||||||
|
}
|
||||||
|
if (kat && sifra && !jeIzmena) {
|
||||||
|
kat.addEventListener('change', function () {
|
||||||
|
if (sifra.dataset.rucno === '1' && sifra.value !== '') return;
|
||||||
|
fetch('/magacin/sledeca-sifra?kategorija=' + encodeURIComponent(kat.value))
|
||||||
|
.then(function (r) { return r.ok ? r.text() : ''; })
|
||||||
|
.then(function (t) { if (t) sifra.value = t; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -33,6 +33,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="max-width:280px;">
|
||||||
|
<label for="predracun_rok_dana" class="polje-labela">
|
||||||
|
Rok važenja predračuna (dana)
|
||||||
|
</label>
|
||||||
|
<input type="number" id="predracun_rok_dana" name="predracun_rok_dana"
|
||||||
|
min="1" max="90" value="{{.PredracunRokDana}}"
|
||||||
|
style="width:100%;">
|
||||||
|
<div class="pomocni-tekst" style="margin-top:6px;">
|
||||||
|
Koliko dana važi predračun od dana izdavanja. Štampa se na dokumentu kao „Važi do".
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;justify-content:flex-end;margin-top:20px;">
|
<div style="display:flex;justify-content:flex-end;margin-top:20px;">
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
{{range .Stavke}}
|
{{range .Stavke}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.ArtikalNaziv}}</td>
|
<td>{{.ArtikalNaziv}}</td>
|
||||||
<td>{{.Kolicina}}</td>
|
<td>{{.Kolicina}} {{.JedinicaMere}}</td>
|
||||||
<td>{{printf "%.2f" .CenaPoKomadu}} din</td>
|
<td>{{printf "%.2f" .CenaPoKomadu}} din</td>
|
||||||
<td>{{printf "%.2f" .Ukupno}} din</td>
|
<td>{{printf "%.2f" .Ukupno}} din</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -61,6 +61,9 @@
|
|||||||
<a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno">
|
<a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno">
|
||||||
Radni nalog
|
Radni nalog
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/servis/{{.Nalog.ID}}/predracun" target="_blank" class="btn-sekundarno">
|
||||||
|
Predračun
|
||||||
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
|
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
|
||||||
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
|
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Predračun — {{.Nalog.BrojNaloga}}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 13px; color: #111; background: #fff; }
|
||||||
|
|
||||||
|
.strana { max-width: 780px; margin: 0 auto; padding: 32px 40px; }
|
||||||
|
|
||||||
|
/* zaglavlje */
|
||||||
|
.zaglavlje { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 2px solid #111; gap: 20px; }
|
||||||
|
.firma-naziv { font-size: 20px; font-weight: 700; }
|
||||||
|
.firma-info { font-size: 11px; color: #555; margin-top: 4px; line-height: 1.7; }
|
||||||
|
.dok-naslov { text-align: right; }
|
||||||
|
.dok-tip { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #555; margin-bottom: 4px; }
|
||||||
|
.dok-broj { font-size: 22px; font-weight: 700; font-family: monospace; }
|
||||||
|
.dok-datum { font-size: 12px; color: #555; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* strane */
|
||||||
|
.strane-blok { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.strana-kartica { padding: 12px 14px; border: 0.5px solid #ddd; border-radius: 6px; }
|
||||||
|
.strana-tip { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #888; margin-bottom: 6px; }
|
||||||
|
.strana-naziv { font-size: 14px; font-weight: 600; }
|
||||||
|
.strana-info { font-size: 12px; color: #555; margin-top: 3px; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* odeljci */
|
||||||
|
.odeljak { margin-bottom: 18px; }
|
||||||
|
.odeljak-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: #555; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 0.5px solid #ccc; }
|
||||||
|
|
||||||
|
/* podaci */
|
||||||
|
.podaci-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||||
|
.polje-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 3px; }
|
||||||
|
.polje-vrednost { font-size: 13px; font-weight: 500; }
|
||||||
|
.polje-vrednost-mono { font-family: monospace; font-size: 13px; font-weight: 500; }
|
||||||
|
.polje-tekst { font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||||
|
.prazno { color: #aaa; font-style: italic; }
|
||||||
|
|
||||||
|
/* tabela delova */
|
||||||
|
.tabela { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
||||||
|
.tabela th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: #555; padding: 6px 8px; border-bottom: 1.5px solid #111; text-align: left; }
|
||||||
|
.tabela th.desno { text-align: right; }
|
||||||
|
.tabela td { padding: 7px 8px; border-bottom: 0.5px solid #ddd; font-size: 13px; vertical-align: middle; }
|
||||||
|
.tabela td.desno { text-align: right; }
|
||||||
|
.tabela .ukupno-red td { font-weight: 600; border-top: 1.5px solid #111; border-bottom: none; background: #fafafa; }
|
||||||
|
|
||||||
|
/* iznos procene */
|
||||||
|
.naplata-blok { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border: 1.5px solid #111; border-radius: 6px; margin-top: 12px; }
|
||||||
|
.naplata-labela { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||||
|
.naplata-iznos { font-size: 22px; font-weight: 700; }
|
||||||
|
|
||||||
|
/* rok važenja */
|
||||||
|
.rok-blok { padding: 10px 14px; border: 0.5px solid #fcd34d; background: #fffbeb; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.rok-tekst { font-size: 12px; color: #92400e; font-weight: 500; }
|
||||||
|
.rok-datum { font-size: 15px; font-weight: 700; color: #92400e; }
|
||||||
|
|
||||||
|
/* potpisi */
|
||||||
|
.potpisi { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 36px; padding-top: 16px; border-top: 0.5px solid #ccc; }
|
||||||
|
.potpis-linija { border-top: 1px solid #888; margin-top: 44px; padding-top: 6px; font-size: 11px; color: #555; text-align: center; }
|
||||||
|
|
||||||
|
/* napomena na dnu */
|
||||||
|
.napomena-dno { margin-top: 20px; padding: 10px 14px; background: #f9fafb; border-left: 3px solid #ddd; font-size: 12px; color: #555; line-height: 1.6; }
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body { font-size: 12px; }
|
||||||
|
.strana { padding: 16px 20px; max-width: 100%; }
|
||||||
|
.dugme-stampa { display: none !important; }
|
||||||
|
@page { size: A4; margin: 12mm 14mm; }
|
||||||
|
}
|
||||||
|
.dugme-stampa { position: fixed; bottom: 24px; right: 24px; background: #111; color: #fff; border: none; padding: 12px 22px; border-radius: 8px; font-size: 14px; 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">
|
||||||
|
|
||||||
|
<!-- zaglavlje -->
|
||||||
|
<div class="zaglavlje">
|
||||||
|
<div>
|
||||||
|
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
|
||||||
|
<div class="firma-info">
|
||||||
|
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
|
||||||
|
{{if .Adresa}}{{.Adresa}}<br>{{end}}
|
||||||
|
{{if .Telefon}}Tel: {{.Telefon}}<br>{{end}}
|
||||||
|
{{if .PIB}}PIB: {{.PIB}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:14px;">
|
||||||
|
<div class="dok-naslov">
|
||||||
|
<div class="dok-tip">Predračun / Ponuda</div>
|
||||||
|
<div class="dok-broj">{{.Nalog.BrojNaloga}}</div>
|
||||||
|
<div class="dok-datum">Datum izdavanja: {{.DatumIzdavanja.Format "02.01.2006."}}</div>
|
||||||
|
<div class="dok-datum">Važi do: {{.VaziDo.Format "02.01.2006."}}</div>
|
||||||
|
</div>
|
||||||
|
{{if .QRKod}}
|
||||||
|
<img src="data:image/png;base64,{{.QRKod}}" width="76" height="76"
|
||||||
|
alt="QR {{.Nalog.BrojNaloga}}"
|
||||||
|
style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- izdavalac i klijent -->
|
||||||
|
<div class="strane-blok">
|
||||||
|
<div class="strana-kartica">
|
||||||
|
<div class="strana-tip">Izdaje</div>
|
||||||
|
<div class="strana-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}—{{end}}</div>
|
||||||
|
<div class="strana-info">
|
||||||
|
{{if .Adresa}}{{.Adresa}}<br>{{end}}
|
||||||
|
{{if .Telefon}}{{.Telefon}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="strana-kartica">
|
||||||
|
<div class="strana-tip">Za klijenta</div>
|
||||||
|
{{if .KlijentNaziv}}
|
||||||
|
<div class="strana-naziv">{{.KlijentNaziv}}</div>
|
||||||
|
<div class="strana-info">
|
||||||
|
{{if .Klijent}}
|
||||||
|
{{if .Klijent.Telefon}}Tel: {{.Klijent.Telefon}}<br>{{end}}
|
||||||
|
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
|
||||||
|
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="strana-naziv prazno">— klijent nije naveden —</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- uređaj na servisu -->
|
||||||
|
<div class="odeljak">
|
||||||
|
<div class="odeljak-naslov">Uređaj na servisu</div>
|
||||||
|
<div class="podaci-grid" style="margin-bottom:10px;">
|
||||||
|
<div>
|
||||||
|
<div class="polje-labela">Naziv uređaja</div>
|
||||||
|
<div class="polje-vrednost">{{.Nalog.Uredjaj}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="polje-labela">Serijski broj</div>
|
||||||
|
<div class="polje-vrednost-mono">{{if .Nalog.SerijskiBroj}}{{.Nalog.SerijskiBroj}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
{{if .TehnicarNaziv}}
|
||||||
|
<div>
|
||||||
|
<div class="polje-labela">Tehničar</div>
|
||||||
|
<div class="polje-vrednost">{{.TehnicarNaziv}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- opis kvara -->
|
||||||
|
<div class="odeljak">
|
||||||
|
<div class="odeljak-naslov">Utvrđeni kvar</div>
|
||||||
|
<div class="polje-tekst">{{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- predloženi delovi -->
|
||||||
|
{{if .ServisniDelovi}}
|
||||||
|
<div class="odeljak">
|
||||||
|
<div class="odeljak-naslov">Predloženi delovi i materijal</div>
|
||||||
|
<table class="tabela">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artikal</th>
|
||||||
|
<th class="desno" style="width:70px;">Kol.</th>
|
||||||
|
<th class="desno" style="width:120px;">Cena/kom</th>
|
||||||
|
<th class="desno" style="width:120px;">Ukupno</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .ServisniDelovi}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ArtikalNaziv}}</td>
|
||||||
|
<td class="desno">{{.Kolicina}}</td>
|
||||||
|
<td class="desno">{{printf "%.2f" .CenaKomada}} din</td>
|
||||||
|
<td class="desno">{{printf "%.2f" .Ukupno}} din</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="ukupno-red">
|
||||||
|
<td colspan="3">Ukupno delovi</td>
|
||||||
|
<td class="desno">{{printf "%.2f" .UkupnoDelovi}} din</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- procena troška -->
|
||||||
|
{{if .ImaCenuRada}}
|
||||||
|
<div class="odeljak">
|
||||||
|
<div class="odeljak-naslov">Procena troška</div>
|
||||||
|
<table class="tabela" style="margin-bottom:10px;">
|
||||||
|
<tbody>
|
||||||
|
{{if .CenaRaspon}}
|
||||||
|
<tr>
|
||||||
|
<td>Cena rada (procena)</td>
|
||||||
|
<td class="desno" style="width:200px;">{{printf "%.2f" .CenaRadaOd}} – {{printf "%.2f" .CenaRadaDo}} din</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td>Cena rada</td>
|
||||||
|
<td class="desno" style="width:200px;">{{printf "%.2f" .CenaRada}} din</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{if gt .UkupnoDelovi 0.0}}
|
||||||
|
<tr>
|
||||||
|
<td>Predloženi delovi i materijal</td>
|
||||||
|
<td class="desno">{{printf "%.2f" .UkupnoDelovi}} din</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="naplata-blok">
|
||||||
|
<div class="naplata-labela">Procena ukupnog troška:</div>
|
||||||
|
{{if .CenaRaspon}}
|
||||||
|
<div class="naplata-iznos">{{printf "%.2f" .UkupnoOd}} – {{printf "%.2f" .UkupnoDo}} din</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="naplata-iznos">{{printf "%.2f" .Ukupno}} din</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="odeljak">
|
||||||
|
<div class="napomena-dno">Procena troška još nije utvrđena.</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- rok važenja -->
|
||||||
|
<div class="odeljak">
|
||||||
|
<div class="rok-blok">
|
||||||
|
<div class="rok-tekst">Ova ponuda važi do:</div>
|
||||||
|
<div class="rok-datum">{{.VaziDo.Format "02.01.2006."}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Nalog.Napomena}}
|
||||||
|
<div class="napomena-dno"><strong>Napomena:</strong> {{.Nalog.Napomena}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="napomena-dno">
|
||||||
|
Ovo je predračun (ponuda) i ne predstavlja fiskalni dokument. Navedene cene su procena na osnovu
|
||||||
|
utvrđenog kvara; konačan iznos može odstupati ako se tokom servisa otkriju dodatni kvarovi.
|
||||||
|
Servis se započinje nakon saglasnosti klijenta.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- potpisi -->
|
||||||
|
<div class="potpisi">
|
||||||
|
<div>
|
||||||
|
<div class="potpis-linija">Ponudu izdao (firma)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="potpis-linija">Saglasan sa ponudom (klijent)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="dugme-stampa" onclick="window.print()">Štampaj</button>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user