Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8770b5cb26
|
||
|
|
bc60288a0d
|
@@ -392,6 +392,8 @@ func main() {
|
|||||||
r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju)
|
r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju)
|
||||||
r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju)
|
r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju)
|
||||||
r.With(doz("artikal.izmeni")).Post("/magacin/kategorije/dodeli-sifre", h.DodeliSifreArtiklima)
|
r.With(doz("artikal.izmeni")).Post("/magacin/kategorije/dodeli-sifre", h.DodeliSifreArtiklima)
|
||||||
|
r.With(doz("artikal.izmeni")).Get("/magacin/kategorije/uskladi-sifre", h.PregledUskladjivanjaSifri)
|
||||||
|
r.With(doz("artikal.izmeni")).Post("/magacin/kategorije/uskladi-sifre", h.PotvrdiUskladjivanjeSifri)
|
||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke)
|
||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka)
|
||||||
r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku)
|
r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku)
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi")
|
|||||||
// referencirana od strane artikla.
|
// referencirana od strane artikla.
|
||||||
var ErrKategorijaUUpotrebi = errors.New("ntech: kategorija je u upotrebi")
|
var ErrKategorijaUUpotrebi = errors.New("ntech: kategorija je u upotrebi")
|
||||||
|
|
||||||
|
// ErrKategorijaDuplikat se vraća kad naziv ili kôd kategorije već postoji
|
||||||
|
// kod druge kategorije (vidi migraciju 104_kategorije_unique.sql).
|
||||||
|
var ErrKategorijaDuplikat = errors.New("ntech: kategorija sa tim nazivom ili kodom već postoji")
|
||||||
|
|
||||||
|
// ErrArtikalDuplikatSifre se vraća kad šifra ili barkod artikla već postoji
|
||||||
|
// kod drugog artikla (vidi migraciju 055_artikal_sifra_barkod.sql).
|
||||||
|
var ErrArtikalDuplikatSifre = errors.New("ntech: šifra ili barkod artikla već postoji kod drugog artikla")
|
||||||
|
|
||||||
|
// ErrUslugaDuplikatSifre i ErrTrosakDuplikatSifre se vraćaju kad šifra usluge/
|
||||||
|
// troška već postoji kod drugog reda (vidi migraciju 105_usluge_troskovi_sifra_unique.sql).
|
||||||
|
var ErrUslugaDuplikatSifre = errors.New("ntech: šifra usluge već postoji")
|
||||||
|
var ErrTrosakDuplikatSifre = errors.New("ntech: šifra troška već postoji")
|
||||||
|
|
||||||
// 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)
|
||||||
|
|||||||
@@ -227,6 +227,9 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
|
|||||||
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena,
|
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jeUnique(err) {
|
||||||
|
return 0, db.ErrArtikalDuplikatSifre
|
||||||
|
}
|
||||||
return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err)
|
return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +262,9 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
|
|||||||
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.ID,
|
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jeUnique(err) {
|
||||||
|
return db.ErrArtikalDuplikatSifre
|
||||||
|
}
|
||||||
return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err)
|
return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
|
|||||||
return rezultat, nil
|
return rezultat, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jeUnique proverava da li greška iz SQLite drajvera potiče od povrede
|
||||||
|
// UNIQUE indeksa (naziv ili kôd kategorije već postoji — vidi migraciju
|
||||||
|
// 104_kategorije_unique.sql).
|
||||||
|
func jeUnique(err error) bool {
|
||||||
|
var sqliteErr *mosqlite.Error
|
||||||
|
return errors.As(err, &sqliteErr) && sqliteErr.Code() == 2067 // SQLITE_CONSTRAINT_UNIQUE
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
var kod any
|
||||||
@@ -64,6 +72,9 @@ func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int6
|
|||||||
k.Naziv, k.Opis, kod, k.Marza,
|
k.Naziv, k.Opis, kod, k.Marza,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jeUnique(err) {
|
||||||
|
return 0, db.ErrKategorijaDuplikat
|
||||||
|
}
|
||||||
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err)
|
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +120,9 @@ func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error
|
|||||||
k.Naziv, k.Opis, kod, k.Marza, k.ID,
|
k.Naziv, k.Opis, kod, k.Marza, k.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jeUnique(err) {
|
||||||
|
return db.ErrKategorijaDuplikat
|
||||||
|
}
|
||||||
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
|
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -23,18 +23,29 @@ func NoviProdajaRepo(db *sql.DB) *ProdajaRepo {
|
|||||||
// SledeciBroj generiše sledeći broj naloga u formatu PR-GGMM-NNNN
|
// SledeciBroj generiše sledeći broj naloga u formatu PR-GGMM-NNNN
|
||||||
// (GG dvocifrena godina, MM mesec); brojač NNNN se resetuje svakog meseca
|
// (GG dvocifrena godina, MM mesec); brojač NNNN se resetuje svakog meseca
|
||||||
func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
|
func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||||
|
return sledeciBrojProdaje(ctx, r.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sledeciBrojProdaje čita i generiše sledeći broj naloga preko prosleđenog
|
||||||
|
// izvršioca upita (r.db ili tx). Kreiraj je poziva NAD ISTOM transakcijom
|
||||||
|
// u kojoj upisuje nalog — pošto konekcija koristi _txlock=immediate, write
|
||||||
|
// lock se drži od BeginTx, pa je ovo čitanje-pa-upis atomsko i bez race-a
|
||||||
|
// između dva konkurentna/duplirana zahteva.
|
||||||
|
func sledeciBrojProdaje(ctx context.Context, q interface {
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
}) (string, error) {
|
||||||
sada := time.Now()
|
sada := time.Now()
|
||||||
// prefiks "PR-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
// prefiks "PR-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||||
prefiks := fmt.Sprintf("PR-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
prefiks := fmt.Sprintf("PR-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||||
uzorak := prefiks + "%"
|
uzorak := prefiks + "%"
|
||||||
|
|
||||||
var sledeci int
|
var sledeci int
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := q.QueryRowContext(ctx, `
|
||||||
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
|
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
|
||||||
FROM prodajni_nalozi
|
FROM prodajni_nalozi
|
||||||
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
|
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("ntech: ProdajaRepo.SledeciBroj: %w", err)
|
return "", fmt.Errorf("ntech: sledeciBrojProdaje: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s%04d", prefiks, sledeci), nil
|
return fmt.Sprintf("%s%04d", prefiks, sledeci), nil
|
||||||
@@ -184,6 +195,16 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// broj naloga se generiše OVDE, unutar iste transakcije kao insert —
|
||||||
|
// ne sme se prosleđivati kao unapred generisana vrednost iz handlera,
|
||||||
|
// jer bi dupliran/ponovljen POST zahtev tada napravio dva zasebna,
|
||||||
|
// validna naloga sa istim stavkama umesto da drugi bude odbijen.
|
||||||
|
brojNaloga, err := sledeciBrojProdaje(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: broj naloga: %w", err)
|
||||||
|
}
|
||||||
|
n.BrojNaloga = brojNaloga
|
||||||
|
|
||||||
// insert zaglavlja naloga pre stavki da bismo imali nalogID za magacin
|
// insert zaglavlja naloga pre stavki da bismo imali nalogID za magacin
|
||||||
rezultat, err := tx.ExecContext(ctx, `
|
rezultat, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum)
|
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum)
|
||||||
|
|||||||
@@ -98,8 +98,10 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) {
|
|||||||
{ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 1200, PdvStopa: 20},
|
{ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 1200, PdvStopa: 20},
|
||||||
// CenaBezPdv=0 namerno — treba da se auto-izračuna
|
// CenaBezPdv=0 namerno — treba da se auto-izračuna
|
||||||
}
|
}
|
||||||
_, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
|
// BrojNaloga se namerno NE prosleđuje — Kreiraj ga uvek sam generiše
|
||||||
BrojNaloga: "PR-PDV-001", Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(),
|
// (broj_naloga se ne veruje pozivaocu, vidi ProdajaRepo.Kreiraj)
|
||||||
|
nalogID, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
|
||||||
|
Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(),
|
||||||
}, stavke, nil)
|
}, stavke, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Kreiraj: %v", err)
|
t.Fatalf("Kreiraj: %v", err)
|
||||||
@@ -107,7 +109,7 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) {
|
|||||||
|
|
||||||
var cenaBezPdv, pdvIznos float64
|
var cenaBezPdv, pdvIznos float64
|
||||||
baza.QueryRowContext(ctx,
|
baza.QueryRowContext(ctx,
|
||||||
"SELECT cena_bez_pdv, pdv_iznos FROM stavke_prodaje WHERE nalog_id = (SELECT id FROM prodajni_nalozi WHERE broj_naloga='PR-PDV-001')",
|
"SELECT cena_bez_pdv, pdv_iznos FROM stavke_prodaje WHERE nalog_id = ?", nalogID,
|
||||||
).Scan(&cenaBezPdv, &pdvIznos)
|
).Scan(&cenaBezPdv, &pdvIznos)
|
||||||
|
|
||||||
const ocekivanaNeto = 1000.0
|
const ocekivanaNeto = 1000.0
|
||||||
|
|||||||
@@ -31,8 +31,16 @@ func NoviServisRepo(db *sql.DB) *ServisRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SledeciBroj generiše sledeći broj naloga u formatu SN-GGMM-NNN
|
// SledeciBroj generiše sledeći broj naloga u formatu SN-GGMM-NNN
|
||||||
// (GG dvocifrena godina, MM mesec); brojač NNN se resetuje svakog meseca
|
// (GG dvocifrena godina, MM mesec); brojač NNN se resetuje svakog meseca.
|
||||||
|
// Koristi se samo za PRIKAZ predloga na praznoj formi (NoviNalog) — stvarni
|
||||||
|
// broj koji se upisuje generiše Kreiraj iznova, unutar svoje transakcije.
|
||||||
func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||||
|
return sledeciBrojServisa(ctx, r.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sledeciBrojServisa(ctx context.Context, q interface {
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
}) (string, error) {
|
||||||
sada := time.Now()
|
sada := time.Now()
|
||||||
// prefiks "SN-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
// prefiks "SN-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||||
prefiks := fmt.Sprintf("SN-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
prefiks := fmt.Sprintf("SN-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||||
@@ -40,12 +48,12 @@ func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
|||||||
|
|
||||||
// COALESCE(MAX, 0)+1 → prvi nalog u mesecu dobija 001
|
// COALESCE(MAX, 0)+1 → prvi nalog u mesecu dobija 001
|
||||||
var sledeci int
|
var sledeci int
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := q.QueryRowContext(ctx, `
|
||||||
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
|
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
|
||||||
FROM servisni_nalozi
|
FROM servisni_nalozi
|
||||||
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
|
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("ntech: ServisRepo.SledeciBroj: %w", err)
|
return "", fmt.Errorf("ntech: sledeciBrojServisa: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s%03d", prefiks, sledeci), nil
|
return fmt.Sprintf("%s%03d", prefiks, sledeci), nil
|
||||||
@@ -120,14 +128,30 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
|
|||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token
|
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token.
|
||||||
|
// Broj naloga se generiše OVDE, unutar transakcije upisa, i ne veruje se
|
||||||
|
// vrednosti iz forme (koja je samo predlog prikazan pri otvaranju prazne
|
||||||
|
// forme) — tako dupliran/ponovljen POST zahtev ne pravi dva zasebna naloga
|
||||||
|
// sa istim brojem/sadržajem.
|
||||||
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
|
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
|
||||||
token, err := generisiJavniToken()
|
token, err := generisiJavniToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: token: %w", err)
|
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rezultat, err := r.db.ExecContext(ctx, `
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
brojNaloga, err := sledeciBrojServisa(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: broj naloga: %w", err)
|
||||||
|
}
|
||||||
|
n.BrojNaloga = brojNaloga
|
||||||
|
|
||||||
|
rezultat, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO servisni_nalozi
|
INSERT INTO servisni_nalozi
|
||||||
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje,
|
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje,
|
||||||
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, garancija_dana, datum_zavrsetka, predvidjen_datum,
|
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, garancija_dana, datum_zavrsetka, predvidjen_datum,
|
||||||
@@ -150,6 +174,10 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
|
|||||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: last insert id: %w", err)
|
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: last insert id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ func (r *TrosakRepo) Kreiraj(ctx context.Context, t *model.Trosak) (int64, error
|
|||||||
sifra, t.Naziv, t.Cena, t.Opis,
|
sifra, t.Naziv, t.Cena, t.Opis,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jeUnique(err) {
|
||||||
|
return 0, db.ErrTrosakDuplikatSifre
|
||||||
|
}
|
||||||
return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: %w", err)
|
return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: %w", err)
|
||||||
}
|
}
|
||||||
id, err := rez.LastInsertId()
|
id, err := rez.LastInsertId()
|
||||||
@@ -117,6 +120,9 @@ func (r *TrosakRepo) Izmeni(ctx context.Context, t *model.Trosak) error {
|
|||||||
sifra, t.Naziv, t.Cena, t.Opis, t.ID,
|
sifra, t.Naziv, t.Cena, t.Opis, t.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jeUnique(err) {
|
||||||
|
return db.ErrTrosakDuplikatSifre
|
||||||
|
}
|
||||||
return fmt.Errorf("ntech: TrosakRepo.Izmeni: %w", err)
|
return fmt.Errorf("ntech: TrosakRepo.Izmeni: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ func (r *UslugaRepo) Kreiraj(ctx context.Context, u *model.Usluga) (int64, error
|
|||||||
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis,
|
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jeUnique(err) {
|
||||||
|
return 0, db.ErrUslugaDuplikatSifre
|
||||||
|
}
|
||||||
return 0, fmt.Errorf("ntech: UslugaRepo.Kreiraj: %w", err)
|
return 0, fmt.Errorf("ntech: UslugaRepo.Kreiraj: %w", err)
|
||||||
}
|
}
|
||||||
id, err := rez.LastInsertId()
|
id, err := rez.LastInsertId()
|
||||||
@@ -138,6 +141,9 @@ func (r *UslugaRepo) Izmeni(ctx context.Context, u *model.Usluga) error {
|
|||||||
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis, u.ID,
|
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis, u.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if jeUnique(err) {
|
||||||
|
return db.ErrUslugaDuplikatSifre
|
||||||
|
}
|
||||||
return fmt.Errorf("ntech: UslugaRepo.Izmeni: %w", err)
|
return fmt.Errorf("ntech: UslugaRepo.Izmeni: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -88,6 +90,178 @@ func (h *Handler) DodeliSifreArtiklima(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther)
|
http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromenaSifre je jedna stavka plana usklađivanja šifri — artikal čija
|
||||||
|
// trenutna šifra ne odgovara prefiksu njegove kategorije, sa predloženom
|
||||||
|
// novom šifrom.
|
||||||
|
type PromenaSifre struct {
|
||||||
|
ArtikalID int64
|
||||||
|
Naziv string
|
||||||
|
KategorijaNaziv string
|
||||||
|
StaraSifra string
|
||||||
|
NovaSifra string
|
||||||
|
}
|
||||||
|
|
||||||
|
// izracunajPlanUskladjivanjaSifri prolazi kroz sve artikle (po ID-u, redosled
|
||||||
|
// unosa) i za svaki proverava da li šifra odgovara PREFIKS-NNNN obrascu
|
||||||
|
// njegove kategorije (kod kategorije, ili "ART" ako artikal nema kategoriju).
|
||||||
|
// Artikli iz kategorija bez postavljenog koda se preskaču. Novi broj je prvi
|
||||||
|
// slobodan (počev od 0001) za taj prefiks — uzima u obzir i šifre koje već
|
||||||
|
// postoje kod drugih artikala i one dodeljene ranije u istom prolazu.
|
||||||
|
func izracunajPlanUskladjivanjaSifri(artikli []model.ArtikalSaKategorijom, kategorije []model.Kategorija) []PromenaSifre {
|
||||||
|
// Lista artikala dolazi poređana po nazivu — kandidati se obrađuju po ID-u
|
||||||
|
// (redosled unosa), pa se pravi sopstvena poređana kopija.
|
||||||
|
artikli = append([]model.ArtikalSaKategorijom(nil), artikli...)
|
||||||
|
sort.Slice(artikli, func(i, j int) bool { return artikli[i].ID < artikli[j].ID })
|
||||||
|
|
||||||
|
kodPoKategoriji := make(map[int64]string, len(kategorije))
|
||||||
|
for _, k := range kategorije {
|
||||||
|
kodPoKategoriji[k.ID] = k.Kod
|
||||||
|
}
|
||||||
|
|
||||||
|
// zauzeti[prefiks] = skup već iskorišćenih brojeva (iz postojećih šifri svih artikala)
|
||||||
|
zauzeti := make(map[string]map[int]bool)
|
||||||
|
zauzmi := func(prefiks string, broj int) {
|
||||||
|
if zauzeti[prefiks] == nil {
|
||||||
|
zauzeti[prefiks] = make(map[int]bool)
|
||||||
|
}
|
||||||
|
zauzeti[prefiks][broj] = true
|
||||||
|
}
|
||||||
|
parsirajBroj := func(sifra, prefiks string) (int, bool) {
|
||||||
|
rep := prefiks + "-"
|
||||||
|
if !strings.HasPrefix(sifra, rep) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(sifra[len(rep):])
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
prvoSlobodan := func(prefiks string) int {
|
||||||
|
broj := 1
|
||||||
|
for zauzeti[prefiks][broj] {
|
||||||
|
broj++
|
||||||
|
}
|
||||||
|
return broj
|
||||||
|
}
|
||||||
|
|
||||||
|
// popiši postojeće brojeve po prefiksu iz svih trenutnih šifri (oblika PREFIKS-NNNN)
|
||||||
|
for _, a := range artikli {
|
||||||
|
if a.Sifra == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(a.Sifra, "-")
|
||||||
|
if i <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefiks, rep := a.Sifra[:i], a.Sifra[i+1:]
|
||||||
|
if n, err := strconv.Atoi(rep); err == nil && n >= 1 {
|
||||||
|
zauzmi(prefiks, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan []PromenaSifre
|
||||||
|
for _, a := range artikli {
|
||||||
|
ocekivaniPrefiks := "ART"
|
||||||
|
if a.KategorijaID != nil {
|
||||||
|
kod, ima := kodPoKategoriji[*a.KategorijaID]
|
||||||
|
if !ima || kod == "" {
|
||||||
|
continue // kategorija bez koda — preskoči
|
||||||
|
}
|
||||||
|
ocekivaniPrefiks = kod
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, tacna := parsirajBroj(a.Sifra, ocekivaniPrefiks); tacna {
|
||||||
|
continue // šifra već odgovara obrascu za ovu kategoriju
|
||||||
|
}
|
||||||
|
|
||||||
|
broj := prvoSlobodan(ocekivaniPrefiks)
|
||||||
|
zauzmi(ocekivaniPrefiks, broj)
|
||||||
|
novaSifra := fmt.Sprintf("%s-%04d", ocekivaniPrefiks, broj)
|
||||||
|
|
||||||
|
plan = append(plan, PromenaSifre{
|
||||||
|
ArtikalID: a.ID,
|
||||||
|
Naziv: a.Naziv,
|
||||||
|
KategorijaNaziv: a.KategorijaNaziv,
|
||||||
|
StaraSifra: a.Sifra,
|
||||||
|
NovaSifra: novaSifra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodaciUskladjivanjaSifri su podaci za stranicu pregleda usklađivanja šifri
|
||||||
|
type PodaciUskladjivanjaSifri struct {
|
||||||
|
model.PodaciStranice
|
||||||
|
Plan []PromenaSifre
|
||||||
|
}
|
||||||
|
|
||||||
|
// PregledUskladjivanjaSifri prikazuje spisak artikala čija šifra ne odgovara
|
||||||
|
// prefiksu njihove kategorije, sa predlogom nove šifre — pre potvrde.
|
||||||
|
func (h *Handler) PregledUskladjivanjaSifri(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
artikli, err := h.Artikli.Lista(r.Context(), db.ArtikalFilter{})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kategorije, err := h.KategorijeRepo.Lista(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Greška pri učitavanju kategorija", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||||
|
ps.Stranica = "magacin"
|
||||||
|
ps.NaslovStranice = "Usklađivanje šifri"
|
||||||
|
|
||||||
|
h.renderujTemplate(w, "kategorije_uskladi_sifre", PodaciUskladjivanjaSifri{
|
||||||
|
PodaciStranice: ps,
|
||||||
|
Plan: izracunajPlanUskladjivanjaSifri(artikli, kategorije),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PotvrdiUskladjivanjeSifri prima POST sa tačnim spiskom promena prikazanim
|
||||||
|
// na stranici pregleda (artikal_id[] / nova_sifra[]) i upisuje ih. Ne računa
|
||||||
|
// plan iznova — primenjuje tačno ono što je pregledano i potvrđeno.
|
||||||
|
func (h *Handler) PotvrdiUskladjivanjeSifri(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idovi := r.Form["artikal_id"]
|
||||||
|
sifre := r.Form["nova_sifra"]
|
||||||
|
|
||||||
|
broj := 0
|
||||||
|
for i := 0; i < len(idovi) && i < len(sifre); i++ {
|
||||||
|
id, err := strconv.ParseInt(idovi[i], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.Artikli.AzurirajSifru(r.Context(), id, sifre[i]); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
broj++
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// DodajKategoriju prima POST i čuva novu kategoriju
|
// DodajKategoriju prima POST i čuva novu kategoriju
|
||||||
func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok {
|
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok {
|
||||||
@@ -112,6 +286,11 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := h.KategorijeRepo.Kreiraj(r.Context(), k); err != nil {
|
if _, err := h.KategorijeRepo.Kreiraj(r.Context(), k); err != nil {
|
||||||
|
if errors.Is(err, db.ErrKategorijaDuplikat) {
|
||||||
|
middleware.SetFlash(w, r, h.DB, "greska", "Kategorija sa tim nazivom ili kodom već postoji.")
|
||||||
|
http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "Greška pri čuvanju kategorije", http.StatusInternalServerError)
|
http.Error(w, "Greška pri čuvanju kategorije", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,6 +329,11 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.KategorijeRepo.Izmeni(r.Context(), k); err != nil {
|
if err := h.KategorijeRepo.Izmeni(r.Context(), k); err != nil {
|
||||||
|
if errors.Is(err, db.ErrKategorijaDuplikat) {
|
||||||
|
middleware.SetFlash(w, r, h.DB, "greska", "Kategorija sa tim nazivom ili kodom već postoji.")
|
||||||
|
http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "Greška pri čuvanju izmene kategorije", http.StatusInternalServerError)
|
http.Error(w, "Greška pri čuvanju izmene kategorije", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIzracunajPlanUskladjivanjaSifri_PopunjavaPrazninu(t *testing.T) {
|
||||||
|
kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}}
|
||||||
|
artikli := []model.ArtikalSaKategorijom{
|
||||||
|
{Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "KOMP-0001"}, KategorijaNaziv: "Kompjuteri"},
|
||||||
|
{Artikal: model.Artikal{ID: 2, KategorijaID: p(1), Sifra: "KOMP-0003"}, KategorijaNaziv: "Kompjuteri"},
|
||||||
|
{Artikal: model.Artikal{ID: 3, KategorijaID: p(1), Sifra: "STARA-ŠIFRA"}, KategorijaNaziv: "Kompjuteri"},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||||
|
|
||||||
|
if len(plan) != 1 {
|
||||||
|
t.Fatalf("plan = %d stavki, očekivano 1", len(plan))
|
||||||
|
}
|
||||||
|
if plan[0].ArtikalID != 3 || plan[0].NovaSifra != "KOMP-0002" {
|
||||||
|
t.Errorf("plan[0] = %+v, očekivano ArtikalID=3 NovaSifra=KOMP-0002", plan[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIzracunajPlanUskladjivanjaSifri_KategorijaBezKodaSePreskace(t *testing.T) {
|
||||||
|
kategorije := []model.Kategorija{{ID: 1, Naziv: "Ram memorije", Kod: ""}}
|
||||||
|
artikli := []model.ArtikalSaKategorijom{
|
||||||
|
{Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "NASUMICNO"}, KategorijaNaziv: "Ram memorije"},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||||
|
|
||||||
|
if len(plan) != 0 {
|
||||||
|
t.Fatalf("plan = %d stavki, očekivano 0 (kategorija bez koda)", len(plan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIzracunajPlanUskladjivanjaSifri_VecIspravnaSifraSePreskace(t *testing.T) {
|
||||||
|
kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}}
|
||||||
|
artikli := []model.ArtikalSaKategorijom{
|
||||||
|
{Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "KOMP-0001"}, KategorijaNaziv: "Kompjuteri"},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||||
|
|
||||||
|
if len(plan) != 0 {
|
||||||
|
t.Fatalf("plan = %d stavki, očekivano 0 (šifra već ispravna)", len(plan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIzracunajPlanUskladjivanjaSifri_BezKategorijeIdeUnderArt(t *testing.T) {
|
||||||
|
kategorije := []model.Kategorija{}
|
||||||
|
artikli := []model.ArtikalSaKategorijom{
|
||||||
|
{Artikal: model.Artikal{ID: 1, KategorijaID: nil, Sifra: "ART-0001"}},
|
||||||
|
{Artikal: model.Artikal{ID: 2, KategorijaID: nil, Sifra: "NEBITNO"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||||
|
|
||||||
|
if len(plan) != 1 {
|
||||||
|
t.Fatalf("plan = %d stavki, očekivano 1", len(plan))
|
||||||
|
}
|
||||||
|
if plan[0].ArtikalID != 2 || plan[0].NovaSifra != "ART-0002" {
|
||||||
|
t.Errorf("plan[0] = %+v, očekivano ArtikalID=2 NovaSifra=ART-0002", plan[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIzracunajPlanUskladjivanjaSifri_RedosledPoID(t *testing.T) {
|
||||||
|
kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}}
|
||||||
|
artikli := []model.ArtikalSaKategorijom{
|
||||||
|
{Artikal: model.Artikal{ID: 5, KategorijaID: p(1), Sifra: "X"}, KategorijaNaziv: "Kompjuteri"},
|
||||||
|
{Artikal: model.Artikal{ID: 2, KategorijaID: p(1), Sifra: "Y"}, KategorijaNaziv: "Kompjuteri"},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||||
|
|
||||||
|
if len(plan) != 2 {
|
||||||
|
t.Fatalf("plan = %d stavki, očekivano 2", len(plan))
|
||||||
|
}
|
||||||
|
// ulaz je namerno dat u redosledu ID 5 pa 2 — funkcija sama sortira po ID-u
|
||||||
|
// pre dodele brojeva, pa 2 (manji ID) mora dobiti prvi (manji) broj
|
||||||
|
if plan[0].ArtikalID != 2 || plan[0].NovaSifra != "KOMP-0001" {
|
||||||
|
t.Errorf("plan[0] = %+v, očekivano ArtikalID=2 NovaSifra=KOMP-0001", plan[0])
|
||||||
|
}
|
||||||
|
if plan[1].ArtikalID != 5 || plan[1].NovaSifra != "KOMP-0002" {
|
||||||
|
t.Errorf("plan[1] = %+v, očekivano ArtikalID=5 NovaSifra=KOMP-0002", plan[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func p(id int64) *int64 { return &id }
|
||||||
@@ -23,7 +23,7 @@ var saSidebar = []string{
|
|||||||
"dashboard",
|
"dashboard",
|
||||||
"dobavljaci", "dobavljac_forma",
|
"dobavljaci", "dobavljac_forma",
|
||||||
"izvestaji", "prometni_list", "stanje_zaliha", "popis",
|
"izvestaji", "prometni_list", "stanje_zaliha", "popis",
|
||||||
"kategorije",
|
"kategorije", "kategorije_uskladi_sifre",
|
||||||
"klijenti", "klijent_forma",
|
"klijenti", "klijent_forma",
|
||||||
"magacin", "magacin_forma", "magacin_kartica",
|
"magacin", "magacin_forma", "magacin_kartica",
|
||||||
"nabavke", "nabavka_forma", "nabavka_detalji",
|
"nabavke", "nabavka_forma", "nabavka_detalji",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ntech/internal/db"
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
"ntech/internal/middleware"
|
"ntech/internal/middleware"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
@@ -121,7 +123,32 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
id, err := h.Artikli.Kreiraj(r.Context(), &artikal)
|
id, err := h.Artikli.Kreiraj(r.Context(), &artikal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError)
|
poruka := "Greška pri čuvanju artikla."
|
||||||
|
if errors.Is(err, db.ErrArtikalDuplikatSifre) {
|
||||||
|
poruka = "Šifra ili barkod već postoji kod drugog artikla."
|
||||||
|
} else {
|
||||||
|
slog.Error("čuvanje artikla nije uspelo", "error", err)
|
||||||
|
}
|
||||||
|
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||||
|
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||||
|
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||||
|
katIDStr := ""
|
||||||
|
if artikal.KategorijaID != nil {
|
||||||
|
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
|
||||||
|
}
|
||||||
|
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||||
|
ps.Stranica = "magacin"
|
||||||
|
ps.NaslovStranice = "Novi artikal"
|
||||||
|
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||||
|
PodaciStranice: ps,
|
||||||
|
Artikal: artikal,
|
||||||
|
Kategorije: kategorije,
|
||||||
|
KategorijaIDStr: katIDStr,
|
||||||
|
Dobavljaci: dobavljaci,
|
||||||
|
IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)),
|
||||||
|
Greska: poruka,
|
||||||
|
Izmena: false,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
artikal.ID = id
|
artikal.ID = id
|
||||||
@@ -262,7 +289,32 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
artikal.ID = id
|
artikal.ID = id
|
||||||
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
|
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
|
||||||
http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError)
|
poruka := "Greška pri čuvanju izmene."
|
||||||
|
if errors.Is(err, db.ErrArtikalDuplikatSifre) {
|
||||||
|
poruka = "Šifra ili barkod već postoji kod drugog artikla."
|
||||||
|
} else {
|
||||||
|
slog.Error("čuvanje izmene artikla nije uspelo", "artikal_id", id, "error", err)
|
||||||
|
}
|
||||||
|
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||||
|
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||||
|
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||||
|
katIDStr := ""
|
||||||
|
if artikal.KategorijaID != nil {
|
||||||
|
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
|
||||||
|
}
|
||||||
|
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||||
|
ps.Stranica = "magacin"
|
||||||
|
ps.NaslovStranice = "Izmeni artikal"
|
||||||
|
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||||
|
PodaciStranice: ps,
|
||||||
|
Artikal: artikal,
|
||||||
|
Kategorije: kategorije,
|
||||||
|
KategorijaIDStr: katIDStr,
|
||||||
|
Dobavljaci: dobavljaci,
|
||||||
|
IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)),
|
||||||
|
Greska: poruka,
|
||||||
|
Izmena: true,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,14 +276,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
brojNaloga, err := h.ProdajaRepo.SledeciBroj(r.Context())
|
// broj naloga generiše ProdajaRepo.Kreiraj unutar transakcije upisa
|
||||||
if err != nil {
|
|
||||||
slog.Error("greška pri generisanju broja naloga", "error", err)
|
|
||||||
renderujGresku("Greška pri generisanju broja naloga.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nalog.BrojNaloga = brojNaloga
|
|
||||||
nalog.Datum = time.Now()
|
nalog.Datum = time.Now()
|
||||||
|
|
||||||
var ukupno float64
|
var ukupno float64
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -91,7 +92,11 @@ func (h *Handler) SacuvajTrosak(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := h.TroskoviRepo.Kreiraj(r.Context(), &trosak); err != nil {
|
if _, err := h.TroskoviRepo.Kreiraj(r.Context(), &trosak); err != nil {
|
||||||
h.renderujFormuTroska(w, r, trosak, false, "Greška pri čuvanju troška. Pokušajte ponovo.")
|
poruka := "Greška pri čuvanju troška. Pokušajte ponovo."
|
||||||
|
if errors.Is(err, db.ErrTrosakDuplikatSifre) {
|
||||||
|
poruka = "Šifra već postoji kod drugog troška."
|
||||||
|
}
|
||||||
|
h.renderujFormuTroska(w, r, trosak, false, poruka)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther)
|
||||||
@@ -114,7 +119,11 @@ func (h *Handler) SacuvajIzmenuTroska(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.TroskoviRepo.Izmeni(r.Context(), &trosak); err != nil {
|
if err := h.TroskoviRepo.Izmeni(r.Context(), &trosak); err != nil {
|
||||||
h.renderujFormuTroska(w, r, trosak, true, "Greška pri čuvanju troška. Pokušajte ponovo.")
|
poruka := "Greška pri čuvanju troška. Pokušajte ponovo."
|
||||||
|
if errors.Is(err, db.ErrTrosakDuplikatSifre) {
|
||||||
|
poruka = "Šifra već postoji kod drugog troška."
|
||||||
|
}
|
||||||
|
h.renderujFormuTroska(w, r, trosak, true, poruka)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -92,7 +93,11 @@ func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := h.UslugeRepo.Kreiraj(r.Context(), &usluga); err != nil {
|
if _, err := h.UslugeRepo.Kreiraj(r.Context(), &usluga); err != nil {
|
||||||
h.renderujFormuUsluge(w, r, usluga, false, "Greška pri čuvanju usluge. Pokušajte ponovo.")
|
poruka := "Greška pri čuvanju usluge. Pokušajte ponovo."
|
||||||
|
if errors.Is(err, db.ErrUslugaDuplikatSifre) {
|
||||||
|
poruka = "Šifra već postoji kod druge usluge."
|
||||||
|
}
|
||||||
|
h.renderujFormuUsluge(w, r, usluga, false, poruka)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther)
|
||||||
@@ -115,7 +120,11 @@ func (h *Handler) SacuvajIzmenuUsluge(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.UslugeRepo.Izmeni(r.Context(), &usluga); err != nil {
|
if err := h.UslugeRepo.Izmeni(r.Context(), &usluga); err != nil {
|
||||||
h.renderujFormuUsluge(w, r, usluga, true, "Greška pri čuvanju usluge. Pokušajte ponovo.")
|
poruka := "Greška pri čuvanju usluge. Pokušajte ponovo."
|
||||||
|
if errors.Is(err, db.ErrUslugaDuplikatSifre) {
|
||||||
|
poruka = "Šifra već postoji kod druge usluge."
|
||||||
|
}
|
||||||
|
h.renderujFormuUsluge(w, r, usluga, true, poruka)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Sprečava dupliranje kategorije: isti naziv (bez obzira na velika/mala slova)
|
||||||
|
-- ili isti kôd (prefiks šifre artikla) ne sme postojati kod dve kategorije.
|
||||||
|
-- Kôd je NULL kad nije postavljen (fallback prefiks ART), pa ga izuzimamo iz
|
||||||
|
-- provere jedinstvenosti — više kategorija sme biti bez koda.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_kategorije_naziv ON kategorije(naziv COLLATE NOCASE);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_kategorije_kod ON kategorije(kod) WHERE kod IS NOT NULL;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Šifra usluge/troška treba da bude jedinstvena, isti obrazac kao artikal
|
||||||
|
-- šifra/barkod (migracija 055_artikal_sifra_barkod.sql). Prazna šifra ('') se
|
||||||
|
-- ne čuva (handler šalje NULL kad je polje prazno), pa je izuzimamo da više
|
||||||
|
-- usluga/troškova bez šifre ne bi sudarilo proveru.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_usluge_sifra ON usluge(sifra) WHERE sifra IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_troskovi_sifra ON troskovi(sifra) WHERE sifra IS NOT NULL;
|
||||||
@@ -1,3 +1,26 @@
|
|||||||
|
// generička zaštita od duplog slanja forme: onemogući submit dugme(ad) čim
|
||||||
|
// forma krene da se šalje (spor internet/dupli klik/Enter+klik ne pravi drugi
|
||||||
|
// POST). Dodaj data-ne-onemoguci="1" na formu da se isključi (npr. forme sa
|
||||||
|
// više imenovanih submit dugmadi gde različit klik nosi različitu vrednost).
|
||||||
|
document.addEventListener('submit', function(e) {
|
||||||
|
var forma = e.target;
|
||||||
|
if (!(forma instanceof HTMLFormElement)) return;
|
||||||
|
if (forma.dataset.neOnemoguci === '1') return;
|
||||||
|
var dugmad = forma.querySelectorAll('button[type="submit"]:not([name]), input[type="submit"]:not([name])');
|
||||||
|
setTimeout(function() {
|
||||||
|
dugmad.forEach(function(btn) { btn.disabled = true; });
|
||||||
|
}, 0);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// ako se stranica vrati iz bfcache-a (dugme nazad/napred), vrati dugmad u
|
||||||
|
// normalno stanje da forma ostane upotrebljiva
|
||||||
|
window.addEventListener('pageshow', function(e) {
|
||||||
|
if (!e.persisted) return;
|
||||||
|
document.querySelectorAll('button[type="submit"]:disabled, input[type="submit"]:disabled').forEach(function(btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// otvara/zatvara podmeni u sidebaru — radi i kad je sidebar skupljen i kad je proširen
|
// otvara/zatvara podmeni u sidebaru — radi i kad je sidebar skupljen i kad je proširen
|
||||||
// (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni.
|
// (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni.
|
||||||
function ntechTogglePodmeni(btn) {
|
function ntechTogglePodmeni(btn) {
|
||||||
|
|||||||
@@ -32,11 +32,11 @@
|
|||||||
<div class="kolona" style="gap:20px;">
|
<div class="kolona" style="gap:20px;">
|
||||||
|
|
||||||
<!-- matrica dozvola — editabilna forma -->
|
<!-- matrica dozvola — editabilna forma -->
|
||||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
<div id="dozvole-kartica" class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||||
<div style="padding:14px 16px;border-bottom:0.5px solid var(--ivica);">
|
<div style="padding:14px 16px;border-bottom:0.5px solid var(--ivica);">
|
||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Dozvole po ulogama</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Dozvole po ulogama</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/admin/dozvole/sacuvaj">
|
<form method="POST" action="/admin/dozvole/sacuvaj" data-refresh="#dozvole-kartica">
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="tabela">
|
<table class="tabela">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- zasebna forma za reset, bez vidljivih elemenata -->
|
<!-- zasebna forma za reset, bez vidljivih elemenata -->
|
||||||
<form id="forma-reset" method="POST" action="/admin/dozvole/reset"></form>
|
<form id="forma-reset" method="POST" action="/admin/dozvole/reset" data-refresh="#dozvole-kartica"></form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
{{if eq .Uloga "superadmin"}}
|
{{if eq .Uloga "superadmin"}}
|
||||||
<span class="pomocni-tekst">Superadmin</span>
|
<span class="pomocni-tekst">Superadmin</span>
|
||||||
{{else if eq $.KorisnikUloga "superadmin"}}
|
{{else if eq $.KorisnikUloga "superadmin"}}
|
||||||
<form method="POST" action="/admin/korisnici/{{.ID}}/uloga" style="display:inline;">
|
<form method="POST" action="/admin/korisnici/{{.ID}}/uloga" data-refresh="#lista-korisnika-kartica" style="display:inline;">
|
||||||
<select name="uloga" onchange="this.form.submit()"
|
<select name="uloga" onchange="this.form.submit()"
|
||||||
style="padding:4px 8px;border:0.5px solid var(--ivica);border-radius:6px;background:var(--pozadina);color:var(--tekst-glavni);font-size:12px;">
|
style="padding:4px 8px;border:0.5px solid var(--ivica);border-radius:6px;background:var(--pozadina);color:var(--tekst-glavni);font-size:12px;">
|
||||||
<option value="admin" {{if eq .Uloga "admin"}}selected{{end}}>Admin</option>
|
<option value="admin" {{if eq .Uloga "admin"}}selected{{end}}>Admin</option>
|
||||||
@@ -74,14 +74,14 @@
|
|||||||
Istorija prijava
|
Istorija prijava
|
||||||
</a>
|
</a>
|
||||||
{{if ne .KorisnickoIme $.KorisnikIme}}
|
{{if ne .KorisnickoIme $.KorisnikIme}}
|
||||||
<form method="POST" action="/admin/korisnici/{{.ID}}/aktivan" style="display:inline;">
|
<form method="POST" action="/admin/korisnici/{{.ID}}/aktivan" data-refresh="#lista-korisnika-kartica" style="display:inline;">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
style="padding:5px 12px;border:0.5px solid var(--ivica);border-radius:6px;background:transparent;color:var(--tekst-sporedni);font-size:12px;cursor:pointer;">
|
style="padding:5px 12px;border:0.5px solid var(--ivica);border-radius:6px;background:transparent;color:var(--tekst-sporedni);font-size:12px;cursor:pointer;">
|
||||||
{{if .Aktivan}}Deaktiviraj{{else}}Aktiviraj{{end}}
|
{{if .Aktivan}}Deaktiviraj{{else}}Aktiviraj{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{if eq .Uloga "radnik"}}
|
{{if eq .Uloga "radnik"}}
|
||||||
<form method="POST" action="/admin/korisnici/{{.ID}}/obrisi" style="display:inline;">
|
<form method="POST" action="/admin/korisnici/{{.ID}}/obrisi" data-refresh="#lista-korisnika-kartica" style="display:inline;">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="btn-obrisi-ghost"
|
class="btn-obrisi-ghost"
|
||||||
data-potvrda="Obrisati korisnika {{.KorisnickoIme}}? Ova radnja je trajna.">
|
data-potvrda="Obrisati korisnika {{.KorisnickoIme}}? Ova radnja je trajna.">
|
||||||
|
|||||||
@@ -19,17 +19,19 @@
|
|||||||
{{define "sadrzaj"}}
|
{{define "sadrzaj"}}
|
||||||
<div class="kolona" style="gap:16px;">
|
<div class="kolona" style="gap:16px;">
|
||||||
|
|
||||||
{{if .Sacuvano}}
|
<div id="kat-poruke">
|
||||||
<div class="poruka-uspeh poruka-animacija">Kategorija je uspešno sačuvana.</div>
|
{{if .Sacuvano}}
|
||||||
{{end}}
|
<div class="poruka-uspeh poruka-animacija">Kategorija je uspešno sačuvana.</div>
|
||||||
{{if .Obrisana}}
|
{{end}}
|
||||||
<div class="poruka-uspeh poruka-animacija">Kategorija je uspešno obrisana.</div>
|
{{if .Obrisana}}
|
||||||
{{end}}
|
<div class="poruka-uspeh poruka-animacija">Kategorija je uspešno obrisana.</div>
|
||||||
{{if .PrikaziSifreDod}}
|
{{end}}
|
||||||
<div class="poruka-uspeh poruka-animacija">
|
{{if .PrikaziSifreDod}}
|
||||||
{{if eq .SifreDodeljene 0}}Svi artikli već imaju šifru.{{else}}Dodeljena šifra za {{.SifreDodeljene}} artikal(a).{{end}}
|
<div class="poruka-uspeh poruka-animacija">
|
||||||
|
{{if eq .SifreDodeljene 0}}Svi artikli već imaju šifru.{{else}}Dodeljena šifra za {{.SifreDodeljene}} artikal(a).{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- nazad dugme -->
|
<!-- nazad dugme -->
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
|
||||||
@@ -38,12 +40,18 @@
|
|||||||
Nazad na magacin
|
Nazad na magacin
|
||||||
</a>
|
</a>
|
||||||
{{if index $.Dozvole "artikal.izmeni"}}
|
{{if index $.Dozvole "artikal.izmeni"}}
|
||||||
<form method="POST" action="/magacin/kategorije/dodeli-sifre">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;"
|
<form method="POST" action="/magacin/kategorije/dodeli-sifre" data-refresh="#kat-poruke, #kategorije-lista">
|
||||||
data-potvrda="Proći kroz sve artikle bez šifre i dodeliti im šifru na osnovu prefiksa njihove kategorije?">
|
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;"
|
||||||
Dodeli šifre artiklima bez šifre
|
data-potvrda="Proći kroz sve artikle bez šifre i dodeliti im šifru na osnovu prefiksa njihove kategorije?">
|
||||||
</button>
|
Dodeli šifre artiklima bez šifre
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="/magacin/kategorije/uskladi-sifre" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;"
|
||||||
|
hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
|
||||||
|
Uskladi šifre sa kategorijama
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,7 +61,7 @@
|
|||||||
<div style="margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
<div style="margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Nova kategorija</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Nova kategorija</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/magacin/kategorije/dodaj">
|
<form method="POST" action="/magacin/kategorije/dodaj" data-refresh="#kat-poruke, #kategorije-lista">
|
||||||
<div class="kolona" style="gap:12px;">
|
<div class="kolona" style="gap:12px;">
|
||||||
<div>
|
<div>
|
||||||
<label class="polje-labela">
|
<label class="polje-labela">
|
||||||
@@ -90,7 +98,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- lista kategorija -->
|
<!-- lista kategorija -->
|
||||||
<div class="kartica" style="padding:0;overflow:hidden;">
|
<div id="kategorije-lista" class="kartica" style="padding:0;overflow:hidden;">
|
||||||
<div style="padding:14px 16px;border-bottom:0.5px solid var(--ivica);">
|
<div style="padding:14px 16px;border-bottom:0.5px solid var(--ivica);">
|
||||||
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Postojeće kategorije</span>
|
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Postojeće kategorije</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +124,7 @@
|
|||||||
<h3>Izmeni kategoriju</h3>
|
<h3>Izmeni kategoriju</h3>
|
||||||
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">×</button>
|
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">×</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/magacin/kategorije/izmeni/{{.ID}}" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
|
<form method="POST" action="/magacin/kategorije/izmeni/{{.ID}}" data-refresh="#kat-poruke, #kategorije-lista" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
|
||||||
<div>
|
<div>
|
||||||
<label class="polje-labela">Naziv <span style="color:#dc2626;">*</span></label>
|
<label class="polje-labela">Naziv <span style="color:#dc2626;">*</span></label>
|
||||||
<input type="text" name="naziv" value="{{.Naziv}}" required
|
<input type="text" name="naziv" value="{{.Naziv}}" required
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "naslov"}}Usklađivanje šifri — NTech{{end}}
|
||||||
|
|
||||||
|
{{define "sadrzaj"}}
|
||||||
|
<div class="kolona" style="gap:16px;">
|
||||||
|
|
||||||
|
<!-- nazad dugme -->
|
||||||
|
<div>
|
||||||
|
<a href="/magacin/kategorije" class="nazad-link" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
Nazad na kategorije
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if not .Plan}}
|
||||||
|
<div class="kartica animiraj">
|
||||||
|
<p style="margin:0;color:var(--tekst-sekundarni);">Sve šifre su već usklađene sa prefiksima svojih kategorija.</p>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||||
|
<div style="padding:14px 16px;border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Predložene izmene ({{len .Plan}})</span>
|
||||||
|
<p class="pomocni-tekst" style="margin:4px 0 0;">Ovo su artikli čija šifra ne odgovara prefiksu njihove kategorije. Pregledaj i potvrdi.</p>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="tabela">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artikal</th>
|
||||||
|
<th>Kategorija</th>
|
||||||
|
<th>Stara šifra</th>
|
||||||
|
<th>Nova šifra</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Plan}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Naziv}}</td>
|
||||||
|
<td>{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
|
||||||
|
<td>{{if .StaraSifra}}{{.StaraSifra}}{{else}}<em>(bez šifre)</em>{{end}}</td>
|
||||||
|
<td><strong>{{.NovaSifra}}</strong></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/magacin/kategorije/uskladi-sifre" style="align-self:flex-end;">
|
||||||
|
{{range .Plan}}
|
||||||
|
<input type="hidden" name="artikal_id" value="{{.ArtikalID}}">
|
||||||
|
<input type="hidden" name="nova_sifra" value="{{.NovaSifra}}">
|
||||||
|
{{end}}
|
||||||
|
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 20px;"
|
||||||
|
data-potvrda="Primeniti prikazane izmene šifri za {{len .Plan}} artikal(a)?">
|
||||||
|
Potvrdi izmenu
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -16,20 +16,22 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Pozadinska slika prijave</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Pozadinska slika prijave</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .LoginPozadina}}
|
<div id="login-pozadina-blok">
|
||||||
<div style="margin-bottom:14px;display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap;">
|
{{if .LoginPozadina}}
|
||||||
<img src="{{.LoginPozadina}}" alt="Trenutna pozadina"
|
<div style="margin-bottom:14px;display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap;">
|
||||||
style="width:160px;height:90px;object-fit:cover;border:0.5px solid var(--ivica);border-radius:8px;">
|
<img src="{{.LoginPozadina}}" alt="Trenutna pozadina"
|
||||||
<form method="POST" action="/podesavanja/login-pozadina/ukloni">
|
style="width:160px;height:90px;object-fit:cover;border:0.5px solid var(--ivica);border-radius:8px;">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<form method="POST" action="/podesavanja/login-pozadina/ukloni" data-refresh="#login-pozadina-blok">
|
||||||
<button type="submit"
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
class="btn-obrisi-ghost" style="padding:7px 14px;border-radius:8px;"
|
<button type="submit"
|
||||||
data-potvrda="Ukloniti pozadinsku sliku?">
|
class="btn-obrisi-ghost" style="padding:7px 14px;border-radius:8px;"
|
||||||
Ukloni sliku
|
data-potvrda="Ukloniti pozadinsku sliku?">
|
||||||
</button>
|
Ukloni sliku
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
<form method="POST" action="/podesavanja/login-pozadina" enctype="multipart/form-data">
|
<form method="POST" action="/podesavanja/login-pozadina" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
|||||||
@@ -15,19 +15,21 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Logo firme</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Logo firme</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .LogoPutanja}}
|
<div id="logo-blok">
|
||||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
{{if .LogoPutanja}}
|
||||||
<img src="{{.LogoPutanja}}" alt="Trenutni logo"
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
||||||
style="max-height:60px;max-width:200px;object-fit:contain;border:0.5px solid var(--ivica);border-radius:8px;padding:6px;background:var(--kartica);flex-shrink:0;">
|
<img src="{{.LogoPutanja}}" alt="Trenutni logo"
|
||||||
<form method="POST" action="/podesavanja/logo/ukloni" style="margin:0;">
|
style="max-height:60px;max-width:200px;object-fit:contain;border:0.5px solid var(--ivica);border-radius:8px;padding:6px;background:var(--kartica);flex-shrink:0;">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<form method="POST" action="/podesavanja/logo/ukloni" data-refresh="#logo-blok" style="margin:0;">
|
||||||
<button type="submit"
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
style="padding:6px 14px;background:none;border:0.5px solid #dc2626;color:#dc2626;border-radius:8px;font-size:13px;cursor:pointer;white-space:nowrap;">
|
<button type="submit"
|
||||||
Ukloni sliku
|
style="padding:6px 14px;background:none;border:0.5px solid #dc2626;color:#dc2626;border-radius:8px;font-size:13px;cursor:pointer;white-space:nowrap;">
|
||||||
</button>
|
Ukloni sliku
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
<form method="POST" action="/podesavanja/logo" enctype="multipart/form-data">
|
<form method="POST" action="/podesavanja/logo" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||||
|
|||||||
@@ -192,28 +192,30 @@
|
|||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Moj avatar</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Moj avatar</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px;">
|
<div id="avatar-blok">
|
||||||
<div style="width:64px;height:64px;border-radius:10px;overflow:hidden;border:0.5px solid var(--ivica);flex-shrink:0;background:var(--pozadina);display:flex;align-items:center;justify-content:center;">
|
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px;">
|
||||||
{{if .AvatarPutanja}}
|
<div style="width:64px;height:64px;border-radius:10px;overflow:hidden;border:0.5px solid var(--ivica);flex-shrink:0;background:var(--pozadina);display:flex;align-items:center;justify-content:center;">
|
||||||
<img src="{{.AvatarPutanja}}" alt="Moj avatar" style="width:100%;height:100%;object-fit:cover;">
|
{{if .AvatarPutanja}}
|
||||||
{{else}}
|
<img src="{{.AvatarPutanja}}" alt="Moj avatar" style="width:100%;height:100%;object-fit:cover;">
|
||||||
<span style="font-size:22px;font-weight:600;color:var(--tekst-sporedni);">{{if .KorisnikIme}}{{slice .KorisnikIme 0 2}}{{else}}NT{{end}}</span>
|
{{else}}
|
||||||
{{end}}
|
<span style="font-size:22px;font-weight:600;color:var(--tekst-sporedni);">{{if .KorisnikIme}}{{slice .KorisnikIme 0 2}}{{else}}NT{{end}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:var(--tekst-sporedni);line-height:1.5;">
|
||||||
|
Slika se prikazuje kao dugme u gornjoj traci.<br>
|
||||||
|
Dozvoljeni formati: JPG, PNG, WebP. Maksimum 2 MB.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13px;color:var(--tekst-sporedni);line-height:1.5;">
|
|
||||||
Slika se prikazuje kao dugme u gornjoj traci.<br>
|
|
||||||
Dozvoljeni formati: JPG, PNG, WebP. Maksimum 2 MB.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{if .AvatarPutanja}}
|
{{if .AvatarPutanja}}
|
||||||
<form method="POST" action="/profil/avatar/ukloni" style="display:inline;margin-bottom:12px;">
|
<form method="POST" action="/profil/avatar/ukloni" data-refresh="#avatar-blok" style="display:inline;margin-bottom:12px;">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
<button type="submit" style="background:none;border:0.5px solid #dc2626;color:#dc2626;padding:6px 14px;border-radius:8px;font-size:13px;cursor:pointer;margin-bottom:12px;">
|
<button type="submit" style="background:none;border:0.5px solid #dc2626;color:#dc2626;padding:6px 14px;border-radius:8px;font-size:13px;cursor:pointer;margin-bottom:12px;">
|
||||||
Ukloni avatar
|
Ukloni avatar
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/profil/avatar" enctype="multipart/form-data">
|
<form method="POST" action="/profil/avatar" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
@@ -240,20 +242,22 @@
|
|||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Moja pozadinska slika</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Moja pozadinska slika</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .LokalnaPozadina}}
|
<div id="pozadina-blok">
|
||||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">
|
{{if .LokalnaPozadina}}
|
||||||
<img src="{{.LokalnaPozadina}}" alt="Trenutna lična pozadina"
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">
|
||||||
style="width:120px;height:70px;object-fit:cover;border-radius:6px;border:0.5px solid var(--ivica);flex-shrink:0;">
|
<img src="{{.LokalnaPozadina}}" alt="Trenutna lična pozadina"
|
||||||
<form method="POST" action="/profil/pozadina/ukloni">
|
style="width:120px;height:70px;object-fit:cover;border-radius:6px;border:0.5px solid var(--ivica);flex-shrink:0;">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<form method="POST" action="/profil/pozadina/ukloni" data-refresh="#pozadina-blok">
|
||||||
<button type="submit"
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
class="btn-obrisi-ghost" style="padding:7px 14px;border-radius:8px;font-size:13px;"
|
<button type="submit"
|
||||||
data-potvrda="Ukloniti ličnu pozadinsku sliku?">
|
class="btn-obrisi-ghost" style="padding:7px 14px;border-radius:8px;font-size:13px;"
|
||||||
Ukloni
|
data-potvrda="Ukloniti ličnu pozadinsku sliku?">
|
||||||
</button>
|
Ukloni
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<form method="POST" action="/profil/pozadina" enctype="multipart/form-data">
|
<form method="POST" action="/profil/pozadina" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
|
|||||||
@@ -25,7 +25,11 @@
|
|||||||
<div class="poruka-greska greska-animacija" style="margin-bottom:16px;">{{.Greska}}</div>
|
<div class="poruka-greska greska-animacija" style="margin-bottom:16px;">{{.Greska}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form id="forma-nalog" method="POST" action="{{if .Izmena}}/servis/izmeni/{{.Nalog.ID}}{{else}}/servis/novi{{end}}"{{if .Izmena}} data-autosave="1"{{end}}>
|
<!-- data-autosave-custom (NE data-autosave) — ova forma ima SOPSTVENI auto-save skript
|
||||||
|
ispod (šalje X-Autosave header, prikazuje status "Čuvam.../Sačuvano ✓"). Generički
|
||||||
|
data-autosave mehanizam iz base.html bi se kačio na isti 'change' event i slao
|
||||||
|
DRUGI, konfliktni POST bez tog headera — otud namerno drugačije ime atributa. -->
|
||||||
|
<form id="forma-nalog" method="POST" action="{{if .Izmena}}/servis/izmeni/{{.Nalog.ID}}{{else}}/servis/novi{{end}}"{{if .Izmena}} data-autosave-custom="1"{{end}}>
|
||||||
|
|
||||||
<!-- broj naloga ide kao hidden — server ga je generisao, vraćamo ga pri POST-u -->
|
<!-- broj naloga ide kao hidden — server ga je generisao, vraćamo ga pri POST-u -->
|
||||||
<input type="hidden" name="broj_naloga" value="{{.Nalog.BrojNaloga}}">
|
<input type="hidden" name="broj_naloga" value="{{.Nalog.BrojNaloga}}">
|
||||||
@@ -311,7 +315,7 @@
|
|||||||
// a za select/date/checkbox na izbor — jedan slušalac pokriva sve tipove.
|
// a za select/date/checkbox na izbor — jedan slušalac pokriva sve tipove.
|
||||||
(function () {
|
(function () {
|
||||||
var forma = document.getElementById('forma-nalog');
|
var forma = document.getElementById('forma-nalog');
|
||||||
if (!forma || forma.dataset.autosave !== '1') return;
|
if (!forma || forma.dataset.autosaveCustom !== '1') return;
|
||||||
var status = document.getElementById('autosave-status');
|
var status = document.getElementById('autosave-status');
|
||||||
var slanjeUToku = false;
|
var slanjeUToku = false;
|
||||||
var ponoviPoZavrsetku = false;
|
var ponoviPoZavrsetku = false;
|
||||||
|
|||||||
@@ -441,6 +441,23 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// flash poruka (SetFlash, npr. greška validacije) možda NIJE postojala
|
||||||
|
// u trenutnom DOM-u pre ovog zahteva — replaceWith gore je ne bi ubacio
|
||||||
|
// (menja samo postojeće elemente), pa je ovde dodajemo posebno ako se
|
||||||
|
// pojavila u svežem odgovoru.
|
||||||
|
var flashNovi = doc.getElementById('flash-toast');
|
||||||
|
if (flashNovi) {
|
||||||
|
var flashStari = document.getElementById('flash-toast');
|
||||||
|
if (flashStari) flashStari.remove();
|
||||||
|
var glavni = document.getElementById('sadrzaj');
|
||||||
|
if (glavni) {
|
||||||
|
glavni.insertBefore(flashNovi, glavni.firstChild);
|
||||||
|
setTimeout(function () {
|
||||||
|
flashNovi.classList.add('flash-izlaz');
|
||||||
|
setTimeout(function () { if (flashNovi.parentElement) flashNovi.remove(); }, 350);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (sc) sc.scrollTop = syDoc;
|
if (sc) sc.scrollTop = syDoc;
|
||||||
var gsNovi = document.getElementById('sadrzaj');
|
var gsNovi = document.getElementById('sadrzaj');
|
||||||
if (gsNovi) gsNovi.scrollTop = syGs;
|
if (gsNovi) gsNovi.scrollTop = syGs;
|
||||||
|
|||||||
Reference in New Issue
Block a user