Prodaja — ispravka duplikacije stavki, provera stanja po artiklu u realnom vremenu

This commit is contained in:
2026-06-02 18:24:54 +02:00
parent def84e1a69
commit 5c744ed15e
4 changed files with 172 additions and 27 deletions
+102 -10
View File
@@ -9,15 +9,89 @@ import (
"ntech/internal/model" "ntech/internal/model"
) )
// Dashboard renderuje početnu stranicu // Dashboard renderuje početnu stranicu sa pravim podacima iz baze
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
// čitamo sva podešavanja iz baze ctx := r.Context()
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB)
if err != nil { if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return return
} }
var brojArtikala, aktivniServisi, prodajaOvogMeseca, kriticnaZaliha int
if err := h.DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM artikli",
).Scan(&brojArtikala); err != nil {
log.Printf("dashboard: broj artikala: %v", err)
}
if err := h.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM servisni_nalozi
WHERE status NOT IN ('Završeno', 'Preuzeto')`,
).Scan(&aktivniServisi); err != nil {
log.Printf("dashboard: aktivni servisi: %v", err)
}
if err := h.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM prodajni_nalozi
WHERE strftime('%Y-%m', datum) = strftime('%Y-%m', 'now', 'localtime')`,
).Scan(&prodajaOvogMeseca); err != nil {
log.Printf("dashboard: prodaja ovog meseca: %v", err)
}
if err := h.DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM artikli WHERE kolicina <= kolicina_min",
).Scan(&kriticnaZaliha); err != nil {
log.Printf("dashboard: kriticna zaliha: %v", err)
}
// poslednjih 5 servisnih naloga
servisRedovi, err := h.DB.QueryContext(ctx, `
SELECT uredjaj, status FROM servisni_nalozi
ORDER BY datum_prijema DESC LIMIT 5`)
if err != nil {
log.Printf("dashboard: poslednji servisi: %v", err)
}
var poslednjiServisi []model.StavkaServisa
if servisRedovi != nil {
defer servisRedovi.Close()
for servisRedovi.Next() {
var s model.StavkaServisa
if err := servisRedovi.Scan(&s.Uredjaj, &s.Status); err == nil {
s.BojaTacke = bojaTackeServisa(s.Status)
poslednjiServisi = append(poslednjiServisi, s)
}
}
}
// artikli sa kritičnom zalihom, sortirani po količini rastuće
zaliheRedovi, err := h.DB.QueryContext(ctx, `
SELECT naziv, kolicina FROM artikli
WHERE kolicina <= kolicina_min
ORDER BY kolicina ASC LIMIT 5`)
if err != nil {
log.Printf("dashboard: kriticne zalihe: %v", err)
}
var kriticneZalihe []model.StavkaZalihe
if zaliheRedovi != nil {
defer zaliheRedovi.Close()
for zaliheRedovi.Next() {
var z model.StavkaZalihe
if err := zaliheRedovi.Scan(&z.Naziv, &z.Kolicina); err == nil {
if z.Kolicina == 0 {
z.BojaTacke = "#dc2626"
} else {
z.BojaTacke = "#f97316"
}
kriticneZalihe = append(kriticneZalihe, z)
}
}
}
podaci := model.PodaciDashboarda{ podaci := model.PodaciDashboarda{
PodaciStranice: model.PodaciStranice{ PodaciStranice: model.PodaciStranice{
Stranica: "dashboard", Stranica: "dashboard",
@@ -29,12 +103,12 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
LogoPutanja: podesavanja["logo_putanja"], LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin", Korisnik: "Admin",
}, },
BrojArtikala: 0, BrojArtikala: brojArtikala,
AktivniServisi: 0, AktivniServisi: aktivniServisi,
ProdajaOvogMeseca: 0, ProdajaOvogMeseca: prodajaOvogMeseca,
KriticnaZaliha: 0, KriticnaZaliha: kriticnaZaliha,
PoslednjiServisi: []model.StavkaServisa{}, PoslednjiServisi: poslednjiServisi,
KriticneZalihe: []model.StavkaZalihe{}, KriticneZalihe: kriticneZalihe,
} }
tmpl, err := template.ParseFiles( tmpl, err := template.ParseFiles(
@@ -44,6 +118,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
"web/templates/stranice/dashboard.html", "web/templates/stranice/dashboard.html",
) )
if err != nil { if err != nil {
log.Printf("greška pri učitavanju šablona: %v", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return return
} }
@@ -51,6 +126,23 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil { if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
log.Printf("greška pri renderovanju: %v", err) log.Printf("greška pri renderovanju: %v", err)
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError) http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
return }
}
// bojaTackeServisa vraća hex boju tačke prema statusu naloga
func bojaTackeServisa(status string) string {
switch status {
case "U dijagnostici":
return "#3b82f6"
case "Čeka delove":
return "#f97316"
case "U popravci":
return "#ca8a04"
case "Završeno":
return "#16a34a"
case "Preuzeto":
return "#15803d"
default:
return "#94a3b8"
} }
} }
+3 -2
View File
@@ -44,16 +44,17 @@ type PodaciDetaljiProdaje struct {
Sacuvano bool Sacuvano bool
} }
// artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom // artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom i stanjem
func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS { func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS {
type stavka struct { type stavka struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Naziv string `json:"naziv"` Naziv string `json:"naziv"`
Cena float64 `json:"cena"` Cena float64 `json:"cena"`
Kolicina int `json:"kolicina"`
} }
lista := make([]stavka, 0, len(artikli)) lista := make([]stavka, 0, len(artikli))
for _, a := range artikli { for _, a := range artikli {
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, Cena: a.ProdajnaCena}) lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, Cena: a.ProdajnaCena, Kolicina: a.Kolicina})
} }
b, _ := json.Marshal(lista) b, _ := json.Marshal(lista)
return template.JS(b) return template.JS(b)
+14 -7
View File
@@ -49,9 +49,14 @@
<div style="width:100%;" <div style="width:100%;"
x-data="{ x-data="{
stavke: [{artikal_id: '', kolicina: 1, cena: 0}], stavke: [{artikal_id: '', kolicina: 1, cena: 0}],
artikliOpcije: _ntechArtikli, artikliOpcije: _ntechArtikli,
isMobile: window.matchMedia('(max-width: 768px)').matches,
init() {
window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
this.isMobile = e.matches;
});
},
dodajStavku() { dodajStavku() {
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0}); this.stavke.push({artikal_id: '', kolicina: 1, cena: 0});
}, },
@@ -199,7 +204,8 @@
<template x-for="(stavka, i) in stavke" :key="i"> <template x-for="(stavka, i) in stavke" :key="i">
<tr style="border-bottom:0.5px solid var(--ivica);"> <tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<select :name="'artikal_id[]'" x-model="stavka.artikal_id" style="width:100%;"> <select :name="'artikal_id[]'" x-model="stavka.artikal_id"
:disabled="isMobile" style="width:100%;">
<option value="">— odaberi artikal —</option> <option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id"> <template x-for="a in artikliOpcije" :key="a.id">
<option :value="a.id" x-text="a.naziv"></option> <option :value="a.id" x-text="a.naziv"></option>
@@ -208,11 +214,11 @@
</td> </td>
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" <input type="number" :name="'kolicina[]'" x-model="stavka.kolicina"
min="1" style="width:100%;text-align:center;"> min="1" :disabled="isMobile" style="width:100%;text-align:center;">
</td> </td>
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" <input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena"
min="0" step="0.01" style="width:100%;text-align:right;"> min="0" step="0.01" :disabled="isMobile" style="width:100%;text-align:right;">
</td> </td>
<td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);"> <td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
<span x-text="ukupnoStavke(stavka) + ' din'"></span> <span x-text="ukupnoStavke(stavka) + ' din'"></span>
@@ -256,7 +262,8 @@
<div style="display:flex;flex-direction:column;gap:10px;"> <div style="display:flex;flex-direction:column;gap:10px;">
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Artikal</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Artikal</label>
<select :name="'artikal_id[]'" x-model="stavka.artikal_id" style="width:100%;"> <select :name="'artikal_id[]'" x-model="stavka.artikal_id"
:disabled="!isMobile" style="width:100%;">
<option value="">— odaberi artikal —</option> <option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id"> <template x-for="a in artikliOpcije" :key="a.id">
<option :value="a.id" x-text="a.naziv"></option> <option :value="a.id" x-text="a.naziv"></option>
@@ -266,11 +273,11 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Količina</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Količina</label>
<input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" min="1" style="width:100%;"> <input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" min="1" :disabled="!isMobile" style="width:100%;">
</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>
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" style="width:100%;"> <input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" :disabled="!isMobile" style="width:100%;">
</div> </div>
</div> </div>
<div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);"> <div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
+50 -5
View File
@@ -39,7 +39,13 @@
x-data="{ x-data="{
stavke: [{artikal_id: '', kolicina: 1, cena: 0}], stavke: [{artikal_id: '', kolicina: 1, cena: 0}],
artikliOpcije: _ntechArtikli, artikliOpcije: _ntechArtikli,
isMobile: window.matchMedia('(max-width: 768px)').matches,
init() {
window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
this.isMobile = e.matches;
});
},
dodajStavku() { dodajStavku() {
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0}); this.stavke.push({artikal_id: '', kolicina: 1, cena: 0});
}, },
@@ -50,6 +56,23 @@
const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id); const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id);
if (a) stavka.cena = a.cena; if (a) stavka.cena = a.cena;
}, },
dostupnaKolicina(i) {
const stavka = this.stavke[i];
if (!stavka.artikal_id) return null;
const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id);
if (!a) return null;
const ostale = this.stavke.reduce((sum, s, j) =>
sum + (j !== i && s.artikal_id == stavka.artikal_id ? (parseInt(s.kolicina) || 0) : 0), 0);
return a.kolicina - ostale;
},
prekoracenje(i) {
const d = this.dostupnaKolicina(i);
if (d === null) return false;
return (parseInt(this.stavke[i].kolicina) || 0) > d;
},
imaPrekoracenja() {
return this.stavke.some((_, i) => this.prekoracenje(i));
},
ukupnoStavke(s) { ukupnoStavke(s) {
return (parseFloat(s.kolicina) * parseFloat(s.cena) || 0).toFixed(2); return (parseFloat(s.kolicina) * parseFloat(s.cena) || 0).toFixed(2);
}, },
@@ -130,6 +153,7 @@
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<select :name="'artikal_id[]'" x-model="stavka.artikal_id" <select :name="'artikal_id[]'" x-model="stavka.artikal_id"
@change="popuniCenu(stavka)" @change="popuniCenu(stavka)"
:disabled="isMobile"
style="width:100%;"> style="width:100%;">
<option value="">— odaberi artikal —</option> <option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id"> <template x-for="a in artikliOpcije" :key="a.id">
@@ -139,11 +163,21 @@
</td> </td>
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" <input type="number" :name="'kolicina[]'" x-model="stavka.kolicina"
min="1" style="width:100%;text-align:center;"> min="1" :disabled="isMobile"
:style="prekoracenje(i) ? 'border-color:#dc2626;' : ''"
style="width:100%;text-align:center;">
<div x-show="stavka.artikal_id"
:style="prekoracenje(i) ? 'color:#dc2626' : 'color:var(--tekst-sporedni)'"
x-text="'Na stanju: ' + dostupnaKolicina(i)"
style="font-size:11px;margin-top:3px;text-align:center;white-space:nowrap;"></div>
<div x-show="prekoracenje(i)"
style="font-size:11px;color:#dc2626;margin-top:1px;text-align:center;white-space:nowrap;">
Prekoračena količina
</div>
</td> </td>
<td style="padding:8px 10px;"> <td style="padding:8px 10px;">
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" <input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena"
min="0" step="0.01" style="width:100%;text-align:right;"> min="0" step="0.01" :disabled="isMobile" style="width:100%;text-align:right;">
</td> </td>
<td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);"> <td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
<span x-text="ukupnoStavke(stavka) + ' din'"></span> <span x-text="ukupnoStavke(stavka) + ' din'"></span>
@@ -189,6 +223,7 @@
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Artikal</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Artikal</label>
<select :name="'artikal_id[]'" x-model="stavka.artikal_id" <select :name="'artikal_id[]'" x-model="stavka.artikal_id"
@change="popuniCenu(stavka)" @change="popuniCenu(stavka)"
:disabled="!isMobile"
style="width:100%;"> style="width:100%;">
<option value="">— odaberi artikal —</option> <option value="">— odaberi artikal —</option>
<template x-for="a in artikliOpcije" :key="a.id"> <template x-for="a in artikliOpcije" :key="a.id">
@@ -199,11 +234,18 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Količina</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Količina</label>
<input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" min="1" style="width:100%;"> <input type="number" :name="'kolicina[]'" x-model="stavka.kolicina" min="1" :disabled="!isMobile"
:style="prekoracenje(i) ? 'border-color:#dc2626;' : ''"
style="width:100%;">
<div x-show="stavka.artikal_id"
:style="prekoracenje(i) ? 'color:#dc2626' : 'color:var(--tekst-sporedni)'"
x-text="'Na stanju: ' + dostupnaKolicina(i)"
style="font-size:11px;margin-top:3px;"></div>
<div x-show="prekoracenje(i)" style="font-size:11px;color:#dc2626;margin-top:1px;">Prekoračena količina</div>
</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>
<input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" style="width:100%;"> <input type="number" :name="'cena_po_komadu[]'" x-model="stavka.cena" min="0" step="0.01" :disabled="!isMobile" style="width:100%;">
</div> </div>
</div> </div>
<div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);"> <div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
@@ -226,8 +268,11 @@
Odustani Odustani
</a> </a>
<button type="submit" <button type="submit"
:disabled="imaPrekoracenja()"
:style="imaPrekoracenja() ? 'opacity:0.4;cursor:not-allowed;' : ''"
style="padding:9px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:opacity 0.2s;" style="padding:9px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:opacity 0.2s;"
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'"> @mouseover="if(!imaPrekoracenja()) $el.style.opacity='0.85'"
@mouseout="$el.style.opacity=imaPrekoracenja()?'0.4':'1'">
Sačuvaj prodaju Sačuvaj prodaju
</button> </button>
</div> </div>