From b0250b29175e76cdd9fa0cabb5c6517cbafa5655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 20 Jun 2026 18:40:01 +0200 Subject: [PATCH] =?UTF-8?q?Artikli:=20=C5=A1ifre,=20tip=20i=20jedinica=20m?= =?UTF-8?q?ere;=20magacin=20UI;=20servis=20predra=C4=8Dun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Š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) --- cmd/ntech/main.go | 17 +- internal/db/repository.go | 14 +- internal/db/sqlite/artikal.go | 124 +++++++-- internal/db/sqlite/kategorija.go | 34 ++- internal/db/sqlite/prodaja.go | 56 ++-- internal/handler/kategorija.go | 15 + internal/handler/kes.go | 2 +- internal/handler/magacin.go | 49 +++- internal/handler/magacin_forma.go | 52 +++- internal/handler/podesavanja.go | 16 ++ internal/handler/servis.go | 148 ++++++++++ internal/model/artikal.go | 17 ++ internal/model/prodaja.go | 19 +- migrations/058_artikal_arhiviran.sql | 4 + migrations/059_kategorija_kod.sql | 2 + migrations/060_artikal_tip_jm.sql | 4 + web/static/css/main.css | 27 +- web/templates/stranice/kategorije.html | 17 +- web/templates/stranice/magacin.html | 71 ++++- web/templates/stranice/magacin_forma.html | 68 ++++- .../stranice/podesavanja_servis.html | 12 + web/templates/stranice/prodaja_stampa.html | 2 +- web/templates/stranice/servis_detalji.html | 3 + web/templates/stranice/servis_predracun.html | 263 ++++++++++++++++++ 24 files changed, 930 insertions(+), 106 deletions(-) create mode 100644 migrations/058_artikal_arhiviran.sql create mode 100644 migrations/059_kategorija_kod.sql create mode 100644 migrations/060_artikal_tip_jm.sql create mode 100644 web/templates/stranice/servis_predracun.html diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 079452b..8eb62b1 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -301,12 +301,14 @@ func main() { r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr) r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica) 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.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal) + r.Get("/magacin/sledeca-sifra", h.PredlogSifre) r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal) 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/vrati/{id}", h.VratiArtikal) 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")).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.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}/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}/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}/predracun", h.StampaPredracuna) 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/{deo_id}/obrisi", h.ObrisiDeloNaloga) r.Get("/izvestaji", h.Izvestaji) - r.Get("/izvestaji/prometni-list", h.PrometniListMagacina) - r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj) - r.Get("/izvestaji/popis", h.Popis) - r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis) + r.Get("/izvestaji/prometni-list", h.PrometniListMagacina) + r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj) + r.Get("/izvestaji/popis", h.Popis) + r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja) r.Get("/prodaja/nova", h.NovaProdaja) r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju) diff --git a/internal/db/repository.go b/internal/db/repository.go index ae743e8..61f74bb 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -2,11 +2,16 @@ package db import ( "context" + "errors" "time" "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 type ArtikalRepository interface { 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 PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error Obrisi(ctx context.Context, id int64) error - // SledecaSifra vraća predlog sledeće auto-šifre (npr. ART-00042) - SledecaSifra(ctx context.Context) (string, error) + // Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju) + 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(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error } @@ -79,6 +88,7 @@ type ArtikalFilter struct { Pretraga string KategorijaID *int64 SamoKriticni bool + Arhivirani bool // true → vrati samo arhivirane; false (podrazumevano) → samo aktivne Limit int Offset int } diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index 694a862..706895e 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -3,10 +3,15 @@ package sqlite import ( "context" "database/sql" + "errors" "fmt" + "strconv" + "strings" "ntech/internal/db" "ntech/internal/model" + + mosqlite "modernc.org/sqlite" ) // 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) { upit := ` 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.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 FROM artikli a 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 { - 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" @@ -71,16 +83,18 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod var kategorijaID sql.NullInt64 var sifra, barkod sql.NullString var marza, katMarza sql.NullFloat64 + var arhiviran int 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.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, ) if err != nil { return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: scan: %w", err) } + a.Arhiviran = arhiviran == 1 if kategorijaID.Valid { a.KategorijaID = &kategorijaID.Int64 @@ -98,7 +112,8 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod 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) } @@ -112,18 +127,20 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, var kategorijaID sql.NullInt64 var sifra, barkod sql.NullString var marza sql.NullFloat64 + var arhiviran int err := r.db.QueryRowContext(ctx, ` - SELECT id, kategorija_id, sifra, barkod, naziv, opis, kolicina, kolicina_min, - lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa + 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, arhiviran 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.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 { return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err) } + a.Arhiviran = arhiviran == 1 if kategorijaID.Valid { 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, ` 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 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, ) if err != nil { @@ -183,12 +200,12 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error { _, err := r.db.ExecContext(ctx, ` UPDATE artikli SET - 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 = ? WHERE id = ?`, - a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Kolicina, - a.KolicinMin, a.Lokacija, + 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.ID, ) if err != nil { @@ -198,14 +215,43 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error { return nil } -// SledecaSifra vraća predlog sledeće auto-šifre u formatu ART-XXXXX -func (r *ArtikalRepo) SledecaSifra(ctx context.Context) (string, error) { - var n int64 - err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM artikli").Scan(&n) +// SledecaSifra vraća predlog sledeće auto-šifre u formatu PREFIKS-NNNN. +// Prefiks je kôd kategorije (npr. KOMP) ili "ART" ako kategorija nema kôd ili nije zadata. +// Brojač je najveći postojeći broj za taj prefiks + 1 (otporno na brisanje artikala). +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 { 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). @@ -229,16 +275,40 @@ func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategori 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 { _, err := r.db.ExecContext(ctx, "DELETE FROM artikli WHERE id = ?", id) 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 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 func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error { tx, err := r.db.BeginTx(ctx, nil) @@ -290,7 +360,13 @@ func (r *ArtikalRepo) PrebrojiPoFilteru(ctx context.Context, filter db.ArtikalFi } 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 diff --git a/internal/db/sqlite/kategorija.go b/internal/db/sqlite/kategorija.go index 928169c..699306a 100644 --- a/internal/db/sqlite/kategorija.go +++ b/internal/db/sqlite/kategorija.go @@ -20,7 +20,7 @@ func NovaKategorijaRepo(db *sql.DB) *KategorijaRepo { // Lista vraća sve kategorije 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 { 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 for redovi.Next() { var k model.Kategorija - var opis sql.NullString + var opis, kod sql.NullString 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) } if opis.Valid { k.Opis = opis.String } + if kod.Valid { + k.Kod = kod.String + } if marza.Valid { k.Marza = &marza.Float64 } @@ -48,9 +51,13 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) // Kreiraj dodaje novu kategoriju 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, - "INSERT INTO kategorije (naziv, opis, marza) VALUES (?, ?, ?)", - k.Naziv, k.Opis, k.Marza, + "INSERT INTO kategorije (naziv, opis, kod, marza) VALUES (?, ?, ?, ?)", + k.Naziv, k.Opis, kod, k.Marza, ) if err != nil { 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 func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) { var k model.Kategorija - var opis sql.NullString + var opis, kod sql.NullString var marza sql.NullFloat64 err := r.db.QueryRowContext(ctx, - "SELECT id, naziv, opis, marza FROM kategorije WHERE id = ?", id). - Scan(&k.ID, &k.Naziv, &opis, &marza) + "SELECT id, naziv, opis, kod, marza FROM kategorije WHERE id = ?", id). + Scan(&k.ID, &k.Naziv, &opis, &kod, &marza) if err != nil { return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err) } if opis.Valid { k.Opis = opis.String } + if kod.Valid { + k.Kod = kod.String + } if marza.Valid { 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 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, - "UPDATE kategorije SET naziv = ?, opis = ?, marza = ? WHERE id = ?", - k.Naziv, k.Opis, k.Marza, k.ID, + "UPDATE kategorije SET naziv = ?, opis = ?, kod = ?, marza = ? WHERE id = ?", + k.Naziv, k.Opis, kod, k.Marza, k.ID, ) if err != nil { return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err) diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index 898b37d..8849585 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -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) { redovi, err := r.db.QueryContext(ctx, ` 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 JOIN artikli a ON a.id = sp.artikal_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.CenaPoKomadu, &s.Ukupno, &s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv, - &s.ArtikalNaziv, + &s.ArtikalNaziv, &s.JedinicaMere, ) if err != nil { 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 for _, s := range stavke { - var naziv string + var naziv, tip string var stanjePre int err := tx.QueryRowContext(ctx, - "SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID, - ).Scan(&naziv, &stanjePre) + "SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID, + ).Scan(&naziv, &stanjePre, &tip) if err != nil { return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err) } - if stanjePre < s.Kolicina { - return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv} - } - - stanjePosle := stanjePre - s.Kolicina - _, err = tx.ExecContext(ctx, - "UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID, - ) - if err != nil { - return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err) + // usluge i troškovi ne prate lager — ne proveravaju se i ne umanjuju + pratiLager := tip == model.TipProizvod || tip == "" + stanjePosle := stanjePre + if pratiLager { + if stanjePre < s.Kolicina { + return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv} + } + stanjePosle = stanjePre - s.Kolicina + _, err = tx.ExecContext(ctx, + "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 @@ -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) } - err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja, - -s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "") - if err != nil { - return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err) + // magacinsku promenu beležimo samo za artikle koji prate lager + if pratiLager { + err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja, + -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 { 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 { 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 _, err = tx.ExecContext(ctx, "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() for _, p := range stavke { + // usluge i troškovi nemaju stanje — vraćamo samo proizvodima _, 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, ) if err != nil { diff --git a/internal/handler/kategorija.go b/internal/handler/kategorija.go index 4e1a1ab..5216d09 100644 --- a/internal/handler/kategorija.go +++ b/internal/handler/kategorija.go @@ -65,6 +65,7 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { k := &model.Kategorija{ Naziv: naziv, Opis: r.FormValue("opis"), + Kod: normalizujKod(r.FormValue("kod")), Marza: parsirajMarzu(r.FormValue("marza")), } @@ -102,6 +103,7 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) { ID: id, Naziv: naziv, Opis: r.FormValue("opis"), + Kod: normalizujKod(r.FormValue("kod")), Marza: parsirajMarzu(r.FormValue("marza")), } @@ -126,6 +128,19 @@ func parsirajMarzu(s string) *float64 { return &v } +// normalizujKod čisti kôd kategorije za upotrebu kao prefiks šifre: +// velika slova, zadržava samo slova i brojeve (bez razmaka i specijalnih znakova). +func normalizujKod(s string) string { + s = strings.ToUpper(strings.TrimSpace(s)) + var b strings.Builder + for _, r := range s { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + // ObrisiKategoriju briše kategoriju po ID-u func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok { diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 29ad212..f5fa3cb 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -39,7 +39,7 @@ var saSidebar = []string{ // standalone su šabloni bez base layouta var standaloneIme = []string{ - "prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_status_javni", + "prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_predracun", "servis_status_javni", } // sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima. diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index 8bd0e4e..1776fb5 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "strconv" @@ -20,6 +21,10 @@ type PodaciMagacina struct { KategorijaIDStr string Sacuvano bool Obrisan bool + Arhiviran bool + Vracen bool + Greska bool + PrikazArhivirani bool // true → lista prikazuje arhivirane umesto aktivnih Premesten bool StranicaBr int UkupnoStranica int @@ -40,6 +45,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { filter := db.ArtikalFilter{ Pretraga: r.URL.Query().Get("pretraga"), SamoKriticni: r.URL.Query().Get("kriticni") == "1", + Arhivirani: r.URL.Query().Get("arhivirani") == "1", } katIDStr := "" @@ -51,7 +57,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { } } - const pageSize = 100 + const pageSize = 50 stranicaBr := 1 if p := r.URL.Query().Get("stranica"); p != "" { if v, err := strconv.Atoi(p); err == nil && v > 0 { @@ -96,6 +102,9 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { if filter.SamoKriticni { queryDelići += "&kriticni=1" } + if filter.Arhivirani { + queryDelići += "&arhivirani=1" + } stranicaPrev := stranicaBr - 1 if stranicaPrev < 1 { @@ -114,6 +123,10 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { KategorijaIDStr: katIDStr, Sacuvano: r.URL.Query().Get("sacuvano") == "1", Obrisan: r.URL.Query().Get("obrisan") == "1", + Arhiviran: r.URL.Query().Get("arhiviran") == "1", + Vracen: r.URL.Query().Get("vracen") == "1", + Greska: r.URL.Query().Get("greska") == "1", + PrikazArhivirani: filter.Arhivirani, Premesten: r.URL.Query().Get("premesten") == "1", StranicaBr: stranicaBr, UkupnoStranica: ukupnoStranica, @@ -169,18 +182,46 @@ func (h *Handler) ObrisiArtikal(w http.ResponseWriter, r *http.Request) { } if err := h.Artikli.Obrisi(r.Context(), id); err != nil { - http.Error(w, "Greška pri brisanju artikla", http.StatusInternalServerError) + // artikal je u prometu — ne brišemo ga, već ga arhiviramo + if errors.Is(err, db.ErrArtikalUUpotrebi) { + if err := h.Artikli.Arhiviraj(r.Context(), id); err != nil { + http.Redirect(w, r, "/magacin?greska=1", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/magacin?arhiviran=1", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/magacin?greska=1", http.StatusSeeOther) return } http.Redirect(w, r, "/magacin?obrisan=1", http.StatusSeeOther) } +// VratiArtikal poništava arhiviranje i vraća artikal u aktivnu listu +func (h *Handler) VratiArtikal(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "artikal.obrisi"); !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Neispravan ID artikla", http.StatusBadRequest) + return + } + + if err := h.Artikli.Vrati(r.Context(), id); err != nil { + http.Redirect(w, r, "/magacin?arhivirani=1&greska=1", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/magacin?arhivirani=1&vracen=1", http.StatusSeeOther) +} + // PodaciMagacinskeKartice su podaci za karticu jednog artikla type PodaciMagacinskeKartice struct { model.PodaciStranice - Artikal model.Artikal - Promene []model.MagacinskaPromenaSaDetaljem + Artikal model.Artikal + Promene []model.MagacinskaPromenaSaDetaljem } // MagacinskaKartica prikazuje sve promene stanja za jedan artikal diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index eb58a28..89bed36 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -37,10 +37,10 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) { return } - predlogSifre, err := h.Artikli.SledecaSifra(r.Context()) + predlogSifre, err := h.Artikli.SledecaSifra(r.Context(), nil) if err != nil { slog.Error("greška pri generisanju predloga šifre", "err", err) - predlogSifre = "ART-00001" + predlogSifre = "ART-0001" } ps := h.popuniPodaciStranice(r, podesavanja) @@ -49,7 +49,7 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) { h.renderujFormuArtikla(w, PodaciFormeArtikla{ PodaciStranice: ps, Kategorije: kategorije, - Artikal: model.Artikal{Sifra: predlogSifre}, + Artikal: model.Artikal{Sifra: predlogSifre, Tip: model.TipProizvod, JedinicaMere: "kom"}, Izmena: false, }) } @@ -94,9 +94,12 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) { return } - // ako korisnik nije uneo šifru, auto-generišemo po ID-u + // ako korisnik nije uneo šifru, auto-generišemo po prefiksu kategorije if artikal.Sifra == "" { - autoSifra := fmt.Sprintf("ART-%05d", id) + autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID) + if e != nil { + autoSifra = fmt.Sprintf("ART-%04d", id) + } artikal.ID = id artikal.Sifra = autoSifra if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { @@ -244,6 +247,22 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) { artikal.Lokacija = r.FormValue("lokacija") artikal.Napomena = r.FormValue("napomena") + // tip artikla — podrazumevano proizvod; usluga i trošak ne prate lager + switch r.FormValue("tip") { + case model.TipUsluga: + artikal.Tip = model.TipUsluga + case model.TipTrosak: + artikal.Tip = model.TipTrosak + default: + artikal.Tip = model.TipProizvod + } + + // jedinica mere — podrazumevano "kom" + artikal.JedinicaMere = r.FormValue("jedinica_mere") + if artikal.JedinicaMere == "" { + artikal.JedinicaMere = "kom" + } + if k := r.FormValue("kolicina"); k != "" { v, err := strconv.Atoi(k) if err != nil || v < 0 { @@ -260,6 +279,12 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) { artikal.KolicinMin = v } + // usluge i troškovi nemaju stanje na lageru + if !artikal.PratiLager() { + artikal.Kolicina = 0 + artikal.KolicinMin = 0 + } + if c := r.FormValue("nabavna_cena"); c != "" { v, err := strconv.ParseFloat(c, 64) if err != nil || v < 0 { @@ -295,6 +320,23 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) { return artikal, "" } +// PredlogSifre vraća predlog auto-šifre za izabranu kategoriju (poziva forma pri promeni kategorije) +func (h *Handler) PredlogSifre(w http.ResponseWriter, r *http.Request) { + var kategorijaID *int64 + if v := r.URL.Query().Get("kategorija"); v != "" { + if id, err := strconv.ParseInt(v, 10, 64); err == nil { + kategorijaID = &id + } + } + sifra, err := h.Artikli.SledecaSifra(r.Context(), kategorijaID) + if err != nil { + http.Error(w, "Greška pri generisanju šifre", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(sifra)) +} + // renderujFormuArtikla renderuje HTML formu za artikal func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) { h.renderujTemplate(w, "magacin_forma", podaci) diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index e0b65bb..26dc87c 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -44,6 +44,7 @@ type PodaciPodesavanja struct { BackupBrojKopija string KalkulacijaMarza string ServisGarancijaMeseci string + PredracunRokDana string LoginPozadina string LoginPozadinaOpacity string LoginPozadinaBlurPozadine string @@ -332,6 +333,20 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { } } + // rok važenja predračuna u danima (1–90) + if v := strings.TrimSpace(r.FormValue("predracun_rok_dana")); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > 90 { + middleware.SetFlash(w, r, h.DB, "greska", "Rok važenja predračuna mora biti broj između 1 i 90 dana.") + http.Redirect(w, r, sledeci, http.StatusSeeOther) + return + } + if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "predracun_rok_dana", strconv.Itoa(n)); err != nil { + http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) + return + } + } + http.Redirect(w, r, sledeci+"?sacuvano=1", http.StatusSeeOther) } @@ -700,6 +715,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"), KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"), ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"), + PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"), }, nil } diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 6b62ae2..11986cf 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -782,6 +782,154 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) { }) } +// PodaciPredracuna su podaci za predračun/ponudu koja se šalje klijentu pre rada +type PodaciPredracuna struct { + Nalog model.ServisniNalog + ServisniDelovi []model.ServisniDeoSaArtiklom + UkupnoDelovi float64 + + ImaCenuRada bool // ima li nalog uopšte cenu rada (raspon ili fiksnu) + CenaRaspon bool // true → prikaži procenu Od–Do; false → fiksnu cenu + CenaRadaOd float64 // donja granica procene + CenaRadaDo float64 // gornja granica procene + CenaRada float64 // fiksna cena rada + + UkupnoOd float64 // delovi + CenaRadaOd (kad je raspon) + UkupnoDo float64 // delovi + CenaRadaDo (kad je raspon) + Ukupno float64 // delovi + fiksna cena (ili samo delovi ako nema cene rada) + + DatumIzdavanja time.Time + VaziDo time.Time + + QRKod string + Klijent *model.Klijent + KlijentNaziv string + TehnicarNaziv string + NazivFirme string + Podnazlov string + Adresa string + Telefon string + PIB string +} + +// StampaPredracuna renderuje predračun/ponudu koja se šalje klijentu kada se utvrdi kvar +func (h *Handler) StampaPredracuna(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) + return + } + + nalog, err := h.ServisRepo.DohvatiID(r.Context(), id) + if err != nil { + http.Error(w, "Nalog nije pronađen", http.StatusNotFound) + return + } + + delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id) + if err != nil { + http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError) + return + } + + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + if err != nil { + http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) + return + } + + var klijent *model.Klijent + klijentNaziv := "" + if nalog.KlijentID != nil { + k, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID) + if err == nil { + klijent = k + if k.NazivFirme != "" { + klijentNaziv = k.NazivFirme + } else { + klijentNaziv = strings.TrimSpace(k.Ime + " " + k.Prezime) + } + } + } + + tehnicarNaziv := "" + if nalog.TehnicarID != nil { + tehnicar, err := h.KorisniciRepo.DohvatiPoID(r.Context(), *nalog.TehnicarID) + if err == nil { + tehnicarNaziv = tehnicar.KorisnickoIme + } + } + + var ukupnoDelovi float64 + for _, d := range delovi { + ukupnoDelovi += d.Ukupno() + } + + // cena rada: ako postoji procena raspona (Od i Do) prikaži je, inače padni na fiksnu cenu + var imaCenuRada, cenaRaspon bool + var cenaRadaOd, cenaRadaDo, cenaRada float64 + var ukupnoOd, ukupnoDo, ukupno float64 + switch { + case nalog.CenaOd != nil && nalog.CenaDo != nil: + imaCenuRada = true + cenaRaspon = true + cenaRadaOd = *nalog.CenaOd + cenaRadaDo = *nalog.CenaDo + ukupnoOd = ukupnoDelovi + cenaRadaOd + ukupnoDo = ukupnoDelovi + cenaRadaDo + case nalog.CenaKonacna != nil: + imaCenuRada = true + cenaRada = *nalog.CenaKonacna + ukupno = ukupnoDelovi + cenaRada + default: + ukupno = ukupnoDelovi + } + + // rok važenja iz podešavanja (default 7 dana) + rok := 7 + if v, err := strconv.Atoi(podesavanja["predracun_rok_dana"]); err == nil && v > 0 { + rok = v + } + datumIzdavanja := time.Now() + vaziDo := datumIzdavanja.AddDate(0, 0, rok) + + // QR kod vodi na javnu status stranicu — dostupnu bez prijave + nalogURL := "http" + if r.TLS != nil { + nalogURL += "s" + } + nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken + var qrKod string + if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { + qrKod = base64.StdEncoding.EncodeToString(png) + } + + h.renderujStandalone(w, "servis_predracun", PodaciPredracuna{ + Nalog: *nalog, + ServisniDelovi: delovi, + UkupnoDelovi: ukupnoDelovi, + ImaCenuRada: imaCenuRada, + CenaRaspon: cenaRaspon, + CenaRadaOd: cenaRadaOd, + CenaRadaDo: cenaRadaDo, + CenaRada: cenaRada, + UkupnoOd: ukupnoOd, + UkupnoDo: ukupnoDo, + Ukupno: ukupno, + DatumIzdavanja: datumIzdavanja, + VaziDo: vaziDo, + QRKod: qrKod, + Klijent: klijent, + KlijentNaziv: klijentNaziv, + TehnicarNaziv: tehnicarNaziv, + NazivFirme: podesavanja["naziv_firme"], + Podnazlov: podesavanja["podnazlov"], + Adresa: podesavanja["adresa"], + Telefon: podesavanja["telefon"], + PIB: podesavanja["pib"], + }) +} + // PromeniStatus obrađuje POST /servis/{id}/status i menja samo status naloga func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { id, err := parseID(chi.URLParam(r, "id")) diff --git a/internal/model/artikal.go b/internal/model/artikal.go index baa357c..f0591b1 100644 --- a/internal/model/artikal.go +++ b/internal/model/artikal.go @@ -2,6 +2,13 @@ package model 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 type Artikal struct { ID int64 @@ -10,6 +17,8 @@ type Artikal struct { Barkod string Naziv string Opis string + Tip string // proizvod | usluga | trosak + JedinicaMere string // kom, sat, set, m, l, kg ... Kolicina int KolicinMin int Lokacija string @@ -19,6 +28,13 @@ type Artikal struct { Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno Napomena string 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 @@ -36,6 +52,7 @@ type Kategorija struct { ID int64 Naziv 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 } diff --git a/internal/model/prodaja.go b/internal/model/prodaja.go index e9a704d..e59b0dc 100644 --- a/internal/model/prodaja.go +++ b/internal/model/prodaja.go @@ -4,15 +4,15 @@ import "time" // ProdajniNalog predstavlja zaglavlje jedne prodaje type ProdajniNalog struct { - ID int64 - KlijentID *int64 - BrojNaloga string - Napomena string - Ukupno float64 - NacinPlacanja string - Stornirano bool - RazlogStorniranja string - Datum time.Time + ID int64 + KlijentID *int64 + BrojNaloga string + Napomena string + Ukupno float64 + NacinPlacanja string + Stornirano bool + RazlogStorniranja string + Datum time.Time } // StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje @@ -38,4 +38,5 @@ type ProdajniNalogSaDetaljem struct { type StavkaProdajeSaArtiklom struct { StavkaProdaje ArtikalNaziv string + JedinicaMere string } diff --git a/migrations/058_artikal_arhiviran.sql b/migrations/058_artikal_arhiviran.sql new file mode 100644 index 0000000..287c196 --- /dev/null +++ b/migrations/058_artikal_arhiviran.sql @@ -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; diff --git a/migrations/059_kategorija_kod.sql b/migrations/059_kategorija_kod.sql new file mode 100644 index 0000000..ccd94ef --- /dev/null +++ b/migrations/059_kategorija_kod.sql @@ -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; diff --git a/migrations/060_artikal_tip_jm.sql b/migrations/060_artikal_tip_jm.sql new file mode 100644 index 0000000..f8ec5ac --- /dev/null +++ b/migrations/060_artikal_tip_jm.sql @@ -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'; diff --git a/web/static/css/main.css b/web/static/css/main.css index c15da08..dd73e36 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -515,6 +515,9 @@ body { font-weight: 500; cursor: pointer; 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; } .btn-primarno-malo:hover { opacity: 0.85; } @@ -536,6 +539,10 @@ body { .btn-sekundarno:hover { background: var(--pozadina); color: var(--tekst-glavni); } /* 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 { display: inline-flex; align-items: center; @@ -548,6 +555,7 @@ body { font-weight: 500; cursor: pointer; text-decoration: none; + white-space: nowrap; transition: opacity 0.2s; } .btn-obrisi-malo:hover { opacity: 0.8; } @@ -1070,6 +1078,16 @@ select { 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. Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */ [data-animacija="bez"] .animiraj, @@ -1121,12 +1139,9 @@ select { .tabela tbody tr:nth-child(20) { animation-delay: 0.80s; } /* content-visibility: auto – browser preskače render elemenata van viewport-a. - Ovo rešava problem sa 1000+ redova gde skrolovanje postaje prazno - dok browser ne stigne da iscrta sve. */ -.tabela tbody tr { - content-visibility: auto; - contain-intrinsic-size: 48px; -} + Stranica je ograničena na 50 redova, pa redovi tabele ne koriste ovu optimizaciju: + procenjena visina (contain-intrinsic-size) se nije poklapala sa stvarnom, pa su + redovi „secali" pri skrolu kad ih browser tek tada izmeri. */ [class*="-kartice"] > .animiraj, .klijenti-kartice > .animiraj { content-visibility: auto; diff --git a/web/templates/stranice/kategorije.html b/web/templates/stranice/kategorije.html index 7c6a362..8f61168 100644 --- a/web/templates/stranice/kategorije.html +++ b/web/templates/stranice/kategorije.html @@ -47,6 +47,12 @@ +
+ + +
Samo slova i brojevi. Prazno = šifre kreću sa ART-.
+
-
{{.Naziv}}
+
+ {{.Naziv}} + {{if .Kod}}{{.Kod}}{{end}} +
{{if .Opis}}
{{.Opis}}
{{end}} @@ -98,6 +107,12 @@
+
+ + +
Artikal je uspešno obrisan.
{{end}} + {{if .Arhiviran}} +
Artikal je u prometu pa je arhiviran umesto obrisan. Možete ga pronaći među arhiviranim artiklima.
+ {{end}} + {{if .Vracen}} +
Artikal je vraćen u aktivnu listu.
+ {{end}} + {{if .Greska}} +
Operacija nije uspela. Pokušajte ponovo.
+ {{end}} {{if .Premesten}}
Artikal je premešten u drugu kategoriju.
{{end}} @@ -26,10 +35,10 @@
-
@@ -48,6 +57,12 @@ hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true"> Samo kritični + @@ -63,6 +78,7 @@ + @@ -74,14 +90,19 @@ {{range .Artikli}} - + + @@ -128,13 +153,15 @@
-
{{.Naziv}}
+ {{.Naziv}} + {{if .Sifra}} +
{{.Sifra}}
+ {{end}} {{if .KategorijaNaziv}}
{{.KategorijaNaziv}}
{{end}}
- Kartica {{if index $.Dozvole "artikal.izmeni"}} Izmeni {{template "promeniCenuMeni" (dict "ID" .ID "Cena" .ProdajnaCena)}} @@ -143,17 +170,29 @@ {{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}} {{end}}{{end}} {{if index $.Dozvole "artikal.obrisi"}} + {{if $.PrikazArhivirani}} + + Vrati + + {{else}} Obriši {{end}} + {{end}}
+ {{if .PratiLager}} Količina: - {{.Kolicina}} + {{.Kolicina}} {{.JedinicaMere}} + {{else}} + Tip: + {{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}} + {{end}}
Cena: {{printf "%.0f" .ProdajnaCena}} din @@ -190,6 +229,20 @@
+ + {{end}} {{/* padajući meni za premeštanje artikla — prima dict {ID, Kategorije}; koristi se i u tabeli i u mobilnoj kartici */}} diff --git a/web/templates/stranice/magacin_forma.html b/web/templates/stranice/magacin_forma.html index 32cd0ce..a1e3f9d 100644 --- a/web/templates/stranice/magacin_forma.html +++ b/web/templates/stranice/magacin_forma.html @@ -29,8 +29,8 @@
-
Ako ostaviš prazno, šifra se automatski dodeljuje.
@@ -53,6 +53,32 @@ style="width:100%;">
+ +
+
+ + +
+
+ + {{$jm := .Artikal.JedinicaMere}} + +
+
+
@@ -72,8 +98,8 @@ style="width:100%;resize:vertical;">{{.Artikal.Opis}}
- -
+ +
@@ -102,8 +128,8 @@ placeholder="prazno = po kategoriji / globalna">
- -
+ +
+ + {{end}} diff --git a/web/templates/stranice/podesavanja_servis.html b/web/templates/stranice/podesavanja_servis.html index 2023823..cd37188 100644 --- a/web/templates/stranice/podesavanja_servis.html +++ b/web/templates/stranice/podesavanja_servis.html @@ -33,6 +33,18 @@
+
+ + +
+ Koliko dana važi predračun od dana izdavanja. Štampa se na dokumentu kao „Važi do". +
+
+
diff --git a/web/templates/stranice/prodaja_stampa.html b/web/templates/stranice/prodaja_stampa.html index e7e339b..62b61bb 100644 --- a/web/templates/stranice/prodaja_stampa.html +++ b/web/templates/stranice/prodaja_stampa.html @@ -81,7 +81,7 @@ {{range .Stavke}}
- + diff --git a/web/templates/stranice/servis_detalji.html b/web/templates/stranice/servis_detalji.html index 7f00646..da78797 100644 --- a/web/templates/stranice/servis_detalji.html +++ b/web/templates/stranice/servis_detalji.html @@ -61,6 +61,9 @@ Radni nalog + + Predračun + {{end}} {{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}} diff --git a/web/templates/stranice/servis_predracun.html b/web/templates/stranice/servis_predracun.html new file mode 100644 index 0000000..3aa716a --- /dev/null +++ b/web/templates/stranice/servis_predracun.html @@ -0,0 +1,263 @@ + + + + + + Predračun — {{.Nalog.BrojNaloga}} + + + +
+ + +
+
+
{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}
+
+ {{if .Podnazlov}}{{.Podnazlov}}
{{end}} + {{if .Adresa}}{{.Adresa}}
{{end}} + {{if .Telefon}}Tel: {{.Telefon}}
{{end}} + {{if .PIB}}PIB: {{.PIB}}{{end}} +
+
+
+
+
Predračun / Ponuda
+
{{.Nalog.BrojNaloga}}
+
Datum izdavanja: {{.DatumIzdavanja.Format "02.01.2006."}}
+
Važi do: {{.VaziDo.Format "02.01.2006."}}
+
+ {{if .QRKod}} + QR {{.Nalog.BrojNaloga}} + {{end}} +
+
+ + +
+
+
Izdaje
+
{{if .NazivFirme}}{{.NazivFirme}}{{else}}—{{end}}
+
+ {{if .Adresa}}{{.Adresa}}
{{end}} + {{if .Telefon}}{{.Telefon}}{{end}} +
+
+
+
Za klijenta
+ {{if .KlijentNaziv}} +
{{.KlijentNaziv}}
+
+ {{if .Klijent}} + {{if .Klijent.Telefon}}Tel: {{.Klijent.Telefon}}
{{end}} + {{if .Klijent.Email}}{{.Klijent.Email}}
{{end}} + {{if .Klijent.Mesto}}{{.Klijent.Mesto}}{{end}} + {{end}} +
+ {{else}} +
— klijent nije naveden —
+ {{end}} +
+
+ + +
+
Uređaj na servisu
+
+
+
Naziv uređaja
+
{{.Nalog.Uredjaj}}
+
+
+
Serijski broj
+
{{if .Nalog.SerijskiBroj}}{{.Nalog.SerijskiBroj}}{{else}}{{end}}
+
+ {{if .TehnicarNaziv}} +
+
Tehničar
+
{{.TehnicarNaziv}}
+
+ {{end}} +
+
+ + +
+
Utvrđeni kvar
+
{{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}{{end}}
+
+ + + {{if .ServisniDelovi}} +
+
Predloženi delovi i materijal
+
Šifra Naziv Kategorija Količina
{{.Naziv}}{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}{{.Naziv}} {{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}} + {{if .PratiLager}} {{.Kolicina}} + {{else}} + {{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}} + {{end}} {{printf "%.0f" .ProdajnaCena}} din @@ -89,9 +110,6 @@
{{.ArtikalNaziv}}{{.Kolicina}}{{.Kolicina}} {{.JedinicaMere}} {{printf "%.2f" .CenaPoKomadu}} din {{printf "%.2f" .Ukupno}} din
+ + + + + + + + + + {{range .ServisniDelovi}} + + + + + + + {{end}} + + + + + +
ArtikalKol.Cena/komUkupno
{{.ArtikalNaziv}}{{.Kolicina}}{{printf "%.2f" .CenaKomada}} din{{printf "%.2f" .Ukupno}} din
Ukupno delovi{{printf "%.2f" .UkupnoDelovi}} din
+ + {{end}} + + + {{if .ImaCenuRada}} +
+
Procena troška
+ + + {{if .CenaRaspon}} + + + + + {{else}} + + + + + {{end}} + {{if gt .UkupnoDelovi 0.0}} + + + + + {{end}} + +
Cena rada (procena){{printf "%.2f" .CenaRadaOd}} – {{printf "%.2f" .CenaRadaDo}} din
Cena rada{{printf "%.2f" .CenaRada}} din
Predloženi delovi i materijal{{printf "%.2f" .UkupnoDelovi}} din
+
+
Procena ukupnog troška:
+ {{if .CenaRaspon}} +
{{printf "%.2f" .UkupnoOd}} – {{printf "%.2f" .UkupnoDo}} din
+ {{else}} +
{{printf "%.2f" .Ukupno}} din
+ {{end}} +
+
+ {{else}} +
+
Procena troška još nije utvrđena.
+
+ {{end}} + + +
+
+
Ova ponuda važi do:
+
{{.VaziDo.Format "02.01.2006."}}
+
+
+ + {{if .Nalog.Napomena}} +
Napomena: {{.Nalog.Napomena}}
+ {{end}} + +
+ 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. +
+ + +
+
+
Ponudu izdao (firma)
+
+
+
Saglasan sa ponudom (klijent)
+
+
+ + + + + + +