From bc60288a0dea383438f2d2a81f63644999d0f497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Tue, 7 Jul 2026 03:50:31 +0200 Subject: [PATCH] =?UTF-8?q?Spre=C4=8Deno=20dupliranje=20prodaje/servisa,?= =?UTF-8?q?=20alat=20za=20uskla=C4=91ivanje=20=C5=A1ifri=20artikla,=20ispr?= =?UTF-8?q?avke=20osve=C5=BEavanja=20stranica,=20za=C5=A1tita=20od=20dupli?= =?UTF-8?q?kata=20kategorije/artikla/usluge/tro=C5=A1ka?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prodaja i servis: broj naloga se generiše unutar transakcije upisa umesto unapred u handleru, čime se otklanja duplo kreiranje naloga pri dvostrukom slanju forme - Servis izmena: uklonjen sudar generičkog i sopstvenog auto-snimanja forme (dva paralelna POST-a) - Magacin: novi alat 'Uskladi šifre sa kategorijama' za masovnu ispravku šifri artikala posle uvođenja šifarnika po kategoriji - Ispravljeno da forme na 9 stranica (kategorije, admin korisnici/dozvole, profil, podešavanja) posle akcije osveže prikaz umesto da ostanu zastarele do F5 - Kategorija, artikal, usluga i trošak: sprečeno dupliranje naziva/šifre/koda (UNIQUE indeksi + razumljive poruke grešaka) --- cmd/ntech/main.go | 2 + internal/db/repository.go | 13 ++ internal/db/sqlite/artikal.go | 6 + internal/db/sqlite/kategorija.go | 14 ++ internal/db/sqlite/prodaja.go | 25 ++- internal/db/sqlite/prodaja_kreiraj_test.go | 8 +- internal/db/sqlite/servis.go | 38 +++- internal/db/sqlite/trosak.go | 6 + internal/db/sqlite/usluga.go | 6 + internal/handler/kategorija.go | 184 ++++++++++++++++++ internal/handler/kategorija_sifre_test.go | 92 +++++++++ internal/handler/kes.go | 2 +- internal/handler/magacin_forma.go | 56 +++++- internal/handler/prodaja.go | 9 +- internal/handler/troskovi.go | 13 +- internal/handler/usluge.go | 13 +- migrations/104_kategorije_unique.sql | 6 + .../105_usluge_troskovi_sifra_unique.sql | 6 + web/static/js/ntech.js | 23 +++ web/templates/stranice/admin_dozvole.html | 6 +- web/templates/stranice/admin_korisnici.html | 6 +- web/templates/stranice/kategorije.html | 46 +++-- .../stranice/kategorije_uskladi_sifre.html | 63 ++++++ .../stranice/podesavanja_izgled.html | 28 +-- web/templates/stranice/podesavanja_opste.html | 26 +-- web/templates/stranice/profil_tema.html | 70 +++---- web/templates/stranice/servis_forma.html | 8 +- web/templates/teme/podrazumevana/base.html | 17 ++ 28 files changed, 682 insertions(+), 110 deletions(-) create mode 100644 internal/handler/kategorija_sifre_test.go create mode 100644 migrations/104_kategorije_unique.sql create mode 100644 migrations/105_usluge_troskovi_sifra_unique.sql create mode 100644 web/templates/stranice/kategorije_uskladi_sifre.html diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 96674dc..90ccc60 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -392,6 +392,8 @@ func main() { 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("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/nova", h.NovaNabavka) r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku) diff --git a/internal/db/repository.go b/internal/db/repository.go index 119c1c9..4e4ec0f 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -16,6 +16,19 @@ var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi") // referencirana od strane artikla. 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 type ArtikalRepository interface { Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error) diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index d6db643..08e5d64 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -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, ) if err != nil { + if jeUnique(err) { + return 0, db.ErrArtikalDuplikatSifre + } 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, ) if err != nil { + if jeUnique(err) { + return db.ErrArtikalDuplikatSifre + } return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err) } diff --git a/internal/db/sqlite/kategorija.go b/internal/db/sqlite/kategorija.go index 8a88ec4..8cc5709 100644 --- a/internal/db/sqlite/kategorija.go +++ b/internal/db/sqlite/kategorija.go @@ -53,6 +53,14 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) 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 func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) { 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, ) if err != nil { + if jeUnique(err) { + return 0, db.ErrKategorijaDuplikat + } 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, ) if err != nil { + if jeUnique(err) { + return db.ErrKategorijaDuplikat + } return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err) } return nil diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index e292228..e03da98 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -23,18 +23,29 @@ func NoviProdajaRepo(db *sql.DB) *ProdajaRepo { // SledeciBroj generiše sledeći broj naloga u formatu PR-GGMM-NNNN // (GG dvocifrena godina, MM mesec); brojač NNNN se resetuje svakog meseca 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() // 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())) uzorak := prefiks + "%" var sledeci int - err := r.db.QueryRowContext(ctx, ` + err := q.QueryRowContext(ctx, ` SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1 FROM prodajni_nalozi WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci) 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 @@ -184,6 +195,16 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk } 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 rezultat, err := tx.ExecContext(ctx, ` INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum) diff --git a/internal/db/sqlite/prodaja_kreiraj_test.go b/internal/db/sqlite/prodaja_kreiraj_test.go index aa9f510..4b857cd 100644 --- a/internal/db/sqlite/prodaja_kreiraj_test.go +++ b/internal/db/sqlite/prodaja_kreiraj_test.go @@ -98,8 +98,10 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) { {ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 1200, PdvStopa: 20}, // CenaBezPdv=0 namerno — treba da se auto-izračuna } - _, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{ - BrojNaloga: "PR-PDV-001", Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(), + // BrojNaloga se namerno NE prosleđuje — Kreiraj ga uvek sam generiše + // (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) if err != nil { t.Fatalf("Kreiraj: %v", err) @@ -107,7 +109,7 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) { var cenaBezPdv, pdvIznos float64 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) const ocekivanaNeto = 1000.0 diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index 1682eb6..93d211f 100644 --- a/internal/db/sqlite/servis.go +++ b/internal/db/sqlite/servis.go @@ -31,8 +31,16 @@ func NoviServisRepo(db *sql.DB) *ServisRepo { } // 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) { + 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() // 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())) @@ -40,12 +48,12 @@ func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) { // COALESCE(MAX, 0)+1 → prvi nalog u mesecu dobija 001 var sledeci int - err := r.db.QueryRowContext(ctx, ` + err := q.QueryRowContext(ctx, ` SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1 FROM servisni_nalozi WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci) 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 @@ -120,14 +128,30 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa 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) { token, err := generisiJavniToken() if err != nil { 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 (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, @@ -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) } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: commit: %w", err) + } + return id, nil } diff --git a/internal/db/sqlite/trosak.go b/internal/db/sqlite/trosak.go index cbed94f..e7b109c 100644 --- a/internal/db/sqlite/trosak.go +++ b/internal/db/sqlite/trosak.go @@ -96,6 +96,9 @@ func (r *TrosakRepo) Kreiraj(ctx context.Context, t *model.Trosak) (int64, error sifra, t.Naziv, t.Cena, t.Opis, ) if err != nil { + if jeUnique(err) { + return 0, db.ErrTrosakDuplikatSifre + } return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: %w", err) } 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, ) if err != nil { + if jeUnique(err) { + return db.ErrTrosakDuplikatSifre + } return fmt.Errorf("ntech: TrosakRepo.Izmeni: %w", err) } return nil diff --git a/internal/db/sqlite/usluga.go b/internal/db/sqlite/usluga.go index 8db77c6..2ad7342 100644 --- a/internal/db/sqlite/usluga.go +++ b/internal/db/sqlite/usluga.go @@ -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, ) if err != nil { + if jeUnique(err) { + return 0, db.ErrUslugaDuplikatSifre + } return 0, fmt.Errorf("ntech: UslugaRepo.Kreiraj: %w", err) } 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, ) if err != nil { + if jeUnique(err) { + return db.ErrUslugaDuplikatSifre + } return fmt.Errorf("ntech: UslugaRepo.Izmeni: %w", err) } return nil diff --git a/internal/handler/kategorija.go b/internal/handler/kategorija.go index e4ac298..10628f3 100644 --- a/internal/handler/kategorija.go +++ b/internal/handler/kategorija.go @@ -2,7 +2,9 @@ package handler import ( "errors" + "fmt" "net/http" + "sort" "strconv" "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) } +// 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 func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { 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 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) 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 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) return } diff --git a/internal/handler/kategorija_sifre_test.go b/internal/handler/kategorija_sifre_test.go new file mode 100644 index 0000000..312b5c2 --- /dev/null +++ b/internal/handler/kategorija_sifre_test.go @@ -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 } diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 515809c..706bba3 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -23,7 +23,7 @@ var saSidebar = []string{ "dashboard", "dobavljaci", "dobavljac_forma", "izvestaji", "prometni_list", "stanje_zaliha", "popis", - "kategorije", + "kategorije", "kategorije_uskladi_sifre", "klijenti", "klijent_forma", "magacin", "magacin_forma", "magacin_kartica", "nabavke", "nabavka_forma", "nabavka_detalji", diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 5c6aaa9..ab5ba20 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "fmt" "log/slog" "net/http" @@ -8,6 +9,7 @@ import ( "strings" "time" + "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" "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) 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 } artikal.ID = id @@ -262,7 +289,32 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { artikal.ID = id 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 } diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index eed3d9c..2f758b4 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -276,14 +276,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { } } - brojNaloga, err := h.ProdajaRepo.SledeciBroj(r.Context()) - if err != nil { - slog.Error("greška pri generisanju broja naloga", "error", err) - renderujGresku("Greška pri generisanju broja naloga.") - return - } - - nalog.BrojNaloga = brojNaloga + // broj naloga generiše ProdajaRepo.Kreiraj unutar transakcije upisa nalog.Datum = time.Now() var ukupno float64 diff --git a/internal/handler/troskovi.go b/internal/handler/troskovi.go index fa43168..29c4af6 100644 --- a/internal/handler/troskovi.go +++ b/internal/handler/troskovi.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "strconv" "strings" @@ -91,7 +92,11 @@ func (h *Handler) SacuvajTrosak(w http.ResponseWriter, r *http.Request) { return } 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 } http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther) @@ -114,7 +119,11 @@ func (h *Handler) SacuvajIzmenuTroska(w http.ResponseWriter, r *http.Request) { return } 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 } http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther) diff --git a/internal/handler/usluge.go b/internal/handler/usluge.go index f9a6cd0..bef475f 100644 --- a/internal/handler/usluge.go +++ b/internal/handler/usluge.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "strconv" "strings" @@ -92,7 +93,11 @@ func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) { return } 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 } http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther) @@ -115,7 +120,11 @@ func (h *Handler) SacuvajIzmenuUsluge(w http.ResponseWriter, r *http.Request) { return } 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 } http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther) diff --git a/migrations/104_kategorije_unique.sql b/migrations/104_kategorije_unique.sql new file mode 100644 index 0000000..1d402ec --- /dev/null +++ b/migrations/104_kategorije_unique.sql @@ -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; diff --git a/migrations/105_usluge_troskovi_sifra_unique.sql b/migrations/105_usluge_troskovi_sifra_unique.sql new file mode 100644 index 0000000..89047b4 --- /dev/null +++ b/migrations/105_usluge_troskovi_sifra_unique.sql @@ -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; diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index 3349eaa..9b57ab3 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -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 // (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni. function ntechTogglePodmeni(btn) { diff --git a/web/templates/stranice/admin_dozvole.html b/web/templates/stranice/admin_dozvole.html index e80b763..70a1267 100644 --- a/web/templates/stranice/admin_dozvole.html +++ b/web/templates/stranice/admin_dozvole.html @@ -32,11 +32,11 @@
-
+
Dozvole po ulogama
-
+
@@ -299,7 +299,7 @@ - + diff --git a/web/templates/stranice/admin_korisnici.html b/web/templates/stranice/admin_korisnici.html index 771e32f..6592607 100644 --- a/web/templates/stranice/admin_korisnici.html +++ b/web/templates/stranice/admin_korisnici.html @@ -38,7 +38,7 @@ {{if eq .Uloga "superadmin"}} Superadmin {{else if eq $.KorisnikUloga "superadmin"}} - + + + + + + {{if not .Plan}} +
+

Sve šifre su već usklađene sa prefiksima svojih kategorija.

+
+ {{else}} +
+
+ Predložene izmene ({{len .Plan}}) +

Ovo su artikli čija šifra ne odgovara prefiksu njihove kategorije. Pregledaj i potvrdi.

+
+
+
+ + + + + + + + + + {{range .Plan}} + + + + + + + {{end}} + +
ArtikalKategorijaStara šifraNova šifra
{{.Naziv}}{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}{{if .StaraSifra}}{{.StaraSifra}}{{else}}(bez šifre){{end}}{{.NovaSifra}}
+
+
+ + + {{range .Plan}} + + + {{end}} + + + {{end}} + +
+{{end}} diff --git a/web/templates/stranice/podesavanja_izgled.html b/web/templates/stranice/podesavanja_izgled.html index 41ef46d..4d5c6f2 100644 --- a/web/templates/stranice/podesavanja_izgled.html +++ b/web/templates/stranice/podesavanja_izgled.html @@ -16,20 +16,22 @@ Pozadinska slika prijave
- {{if .LoginPozadina}} -
- Trenutna pozadina -
- - -
+
+ {{if .LoginPozadina}} +
+ Trenutna pozadina +
+ + +
+
+ {{end}}
- {{end}}
diff --git a/web/templates/stranice/podesavanja_opste.html b/web/templates/stranice/podesavanja_opste.html index df7befd..6547510 100644 --- a/web/templates/stranice/podesavanja_opste.html +++ b/web/templates/stranice/podesavanja_opste.html @@ -15,19 +15,21 @@ Logo firme
- {{if .LogoPutanja}} -
- Trenutni logo - - - - +
+ {{if .LogoPutanja}} +
+ Trenutni logo +
+ + +
+
+ {{end}}
- {{end}}
diff --git a/web/templates/stranice/profil_tema.html b/web/templates/stranice/profil_tema.html index 619d856..92bac1a 100644 --- a/web/templates/stranice/profil_tema.html +++ b/web/templates/stranice/profil_tema.html @@ -192,28 +192,30 @@ Moj avatar
-
-
- {{if .AvatarPutanja}} - Moj avatar - {{else}} - {{if .KorisnikIme}}{{slice .KorisnikIme 0 2}}{{else}}NT{{end}} - {{end}} +
+
+
+ {{if .AvatarPutanja}} + Moj avatar + {{else}} + {{if .KorisnikIme}}{{slice .KorisnikIme 0 2}}{{else}}NT{{end}} + {{end}} +
+
+ Slika se prikazuje kao dugme u gornjoj traci.
+ Dozvoljeni formati: JPG, PNG, WebP. Maksimum 2 MB. +
-
- Slika se prikazuje kao dugme u gornjoj traci.
- Dozvoljeni formati: JPG, PNG, WebP. Maksimum 2 MB. -
-
- {{if .AvatarPutanja}} - - - - - {{end}} + {{if .AvatarPutanja}} +
+ + +
+ {{end}} +
@@ -240,20 +242,22 @@ Moja pozadinska slika
- {{if .LokalnaPozadina}} -
- Trenutna lična pozadina - - - - +
+ {{if .LokalnaPozadina}} +
+ Trenutna lična pozadina +
+ + +
+
+ {{end}}
- {{end}}
diff --git a/web/templates/stranice/servis_forma.html b/web/templates/stranice/servis_forma.html index bca21d3..ef65f0c 100644 --- a/web/templates/stranice/servis_forma.html +++ b/web/templates/stranice/servis_forma.html @@ -25,7 +25,11 @@
{{.Greska}}
{{end}} - + + @@ -311,7 +315,7 @@ // a za select/date/checkbox na izbor — jedan slušalac pokriva sve tipove. (function () { 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 slanjeUToku = false; var ponoviPoZavrsetku = false; diff --git a/web/templates/teme/podrazumevana/base.html b/web/templates/teme/podrazumevana/base.html index 22e369d..bc0581e 100644 --- a/web/templates/teme/podrazumevana/base.html +++ b/web/templates/teme/podrazumevana/base.html @@ -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; var gsNovi = document.getElementById('sadrzaj'); if (gsNovi) gsNovi.scrollTop = syGs;