Servis: vizuelni indikator prekoracenja — backorder i crvena boja

Dozvoljen backorder ugradnje delova (lager ide u minus), handler prebacuje nalog u Ceka delove, a JS boji polja crveno kad je kolicina > stanje. CSS klasa .prekoraceno umesto inline stilova.
This commit is contained in:
2026-06-22 02:05:53 +02:00
parent 63a5c72ca9
commit 110b3e87eb
4 changed files with 59 additions and 20 deletions
+3 -6
View File
@@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"ntech/internal/db"
"ntech/internal/model" "ntech/internal/model"
) )
@@ -66,10 +65,9 @@ func (r *ServisniDeloviRepo) Dodaj(ctx context.Context, nalogID, artikalID int64
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: dohvati artikal: %w", err) return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: dohvati artikal: %w", err)
} }
if stanjePre < kolicina { // dozvoljavamo backorder: deo se može ugraditi i kad nema dovoljno na stanju
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv} // (lager tada ide u minus kao signal da treba nabavka); handler nalog prebacuje
} // u „Čeka delove". Zato ovde NE odbijamo prekoračenje.
stanjePosle := stanjePre - kolicina stanjePosle := stanjePre - kolicina
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID, "UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
@@ -156,4 +154,3 @@ func (r *ServisniDeloviRepo) Obrisi(ctx context.Context, id int64, korisnikID *i
return nil return nil
} }
+34 -11
View File
@@ -525,25 +525,31 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return return
} }
// ako tražena količina prelazi magacinsko stanje, deo se NE ugrađuje (da lager // prepoznaj prekoračenje magacinskog stanja PRE dodavanja (posle dodavanja
// ne ode u minus), a nalog se automatski prebacuje u „Čeka delove" radi nabavke // lager se smanji); deo se svejedno ugrađuje (dozvoljen backorder/minus lager)
if art, e := h.Artikli.DohvatiID(r.Context(), artikalID); e == nil && art != nil && kolicina > art.Kolicina { prekoraceno := false
if e := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusCekaDelove); e != nil { imeArtikla := ""
slog.Error("greška pri prebacivanju naloga u Čeka delove", "error", e) if art, e := h.Artikli.DohvatiID(r.Context(), artikalID); e == nil && art != nil {
} prekoraceno = kolicina > art.Kolicina
middleware.SetFlash(w, r, h.DB, "greska", imeArtikla = art.Naziv
"Nema dovoljno „"+art.Naziv+"\" na stanju (ima "+strconv.Itoa(art.Kolicina)+"). Nalog je prebačen u „Čeka delove\" — naručite deo.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
} }
if _, err := h.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID); err != nil { if _, err := h.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID); err != nil {
slog.Error("greška pri dodavanju dela", "error", err) slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela. Proverite stanje na magacinu.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return return
} }
// prekoračenje → nalog automatski u „Čeka delove" (signal da treba nabavka)
if prekoraceno {
if e := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusCekaDelove); e != nil {
slog.Error("greška pri prebacivanju naloga u Čeka delove", "error", e)
}
middleware.SetFlash(w, r, h.DB, "greska",
"Deo „"+imeArtikla+"\" je dodat iznad stanja na magacinu — nalog je prebačen u „Čeka delove\". Naručite deo.")
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
} }
@@ -1180,6 +1186,23 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Nepoznat status", http.StatusBadRequest) http.Error(w, "Nepoznat status", http.StatusBadRequest)
return return
} }
// zabrana izlaska iz „Čeka delove" dok god neki ugrađeni deo nema pokriće na
// zalihama (lager u minusu / backorder) — deo prvo treba nabaviti
if nalog, e := h.ServisRepo.DohvatiID(r.Context(), id); e == nil &&
nalog.Status == model.StatusCekaDelove && noviStatus != model.StatusCekaDelove {
var bezPokrica int
_ = h.DB.QueryRowContext(r.Context(),
`SELECT COUNT(*) FROM servisni_delovi sd JOIN artikli a ON a.id = sd.artikal_id
WHERE sd.nalog_id = ? AND a.kolicina < 0`, id).Scan(&bezPokrica)
if bezPokrica > 0 {
middleware.SetFlash(w, r, h.DB, "greska",
"Nalog ne može da napusti „Čeka delove\" dok ima ugrađenih delova kojih nema na stanju (lager u minusu). Dopunite zalihe nabavkom.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
}
if err := h.ServisRepo.AzurirajStatus(r.Context(), id, noviStatus); err != nil { if err := h.ServisRepo.AzurirajStatus(r.Context(), id, noviStatus); err != nil {
slog.Error("greška pri promeni statusa naloga", "id", id, "error", err) slog.Error("greška pri promeni statusa naloga", "id", id, "error", err)
http.Error(w, "Greška pri promeni statusa", http.StatusInternalServerError) http.Error(w, "Greška pri promeni statusa", http.StatusInternalServerError)
+5
View File
@@ -1111,6 +1111,11 @@ select {
.bez-anim .tabela tbody tr, .bez-anim .tabela tbody tr,
.bez-anim.tabela tbody tr { animation: none !important; } .bez-anim.tabela tbody tr { animation: none !important; }
/* vizuelni indikator prekoračenja magacinskog stanja (crvena boja)
koristi se u JS (classList.toggle) na #deo-umagacinu i #deo-kol */
.prekoraceno { border-color: #dc2626 !important; }
#deo-umagacinu.prekoraceno { color: #dc2626; border-color: #dc2626; }
/* korisnikova preferencija animacije: body[data-animacija] nadjačava podrazumevano. /* korisnikova preferencija animacije: body[data-animacija] nadjačava podrazumevano.
Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */ Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */
[data-animacija="bez"] .animiraj, [data-animacija="bez"] .animiraj,
+17 -3
View File
@@ -260,11 +260,11 @@
</div> </div>
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">U magacinu</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">U magacinu</label>
<div id="deo-umagacinu" style="font-size:14px;font-weight:500;color:var(--tekst-glavni);text-align:center;padding:8px 0;border:0.5px solid transparent;"></div> <div id="deo-umagacinu" style="width:100%;box-sizing:border-box;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;font-weight:500;text-align:center;background:var(--pozadina);color:var(--tekst-glavni);"></div>
</div> </div>
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Kol.</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Kol.</label>
<input type="number" name="kolicina" id="deo-kol" min="1" value="1" style="width:100%;" required> <input type="number" name="kolicina" id="deo-kol" min="1" value="1" style="width:100%;box-sizing:border-box;" required>
</div> </div>
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Cena/kom (din)</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Cena/kom (din)</label>
@@ -363,8 +363,19 @@
var artikalSel = document.getElementById('deo-artikal'); var artikalSel = document.getElementById('deo-artikal');
var cena = document.getElementById('deo-cena'); var cena = document.getElementById('deo-cena');
var umagacinu = document.getElementById('deo-umagacinu'); var umagacinu = document.getElementById('deo-umagacinu');
var kol = document.getElementById('deo-kol');
var stanjeIzabrano = 0;
if (!artikalSel) return; if (!artikalSel) return;
// boji „U magacinu" i polje količine crveno kada tražimo više nego što ima na
// stanju (dozvoljeno — backorder; nalog će preći u „Čeka delove" pri čuvanju);
// koristi CSS klasu .prekoraceno umesto inline stilova (main.css)
function osveziPrekoracenje() {
var preko = (parseInt(kol && kol.value, 10) || 0) > stanjeIzabrano && artikalSel.value;
if (umagacinu) umagacinu.classList.toggle('prekoraceno', preko);
if (kol) kol.classList.toggle('prekoraceno', preko);
}
if (kategSel) { if (kategSel) {
var kategs = []; var kategs = [];
ARTIKLI.forEach(function (a) { ARTIKLI.forEach(function (a) {
@@ -394,8 +405,11 @@
var c = parseFloat(opt.getAttribute('data-cena')); var c = parseFloat(opt.getAttribute('data-cena'));
if (cena) cena.value = (!isNaN(c) && c > 0) ? c.toFixed(2) : '0'; if (cena) cena.value = (!isNaN(c) && c > 0) ? c.toFixed(2) : '0';
var st = parseInt(opt.getAttribute('data-stanje'), 10); var st = parseInt(opt.getAttribute('data-stanje'), 10);
if (umagacinu) umagacinu.textContent = isNaN(st) ? '—' : st; stanjeIzabrano = isNaN(st) ? 0 : st;
if (umagacinu) umagacinu.textContent = artikalSel.value ? stanjeIzabrano : '—';
osveziPrekoracenje();
}); });
if (kol) kol.addEventListener('input', osveziPrekoracenje);
})(); })();
</script> </script>
{{end}} {{end}}