Servis: potrazivani delovi, garancija, zakljucavanje statusa

Nova tabela servisni_potrazivani_delovi — visak preko stanja ide u potrazivane, lager NE ide u minus. Merge istog artikla na nalogu. Vizuelni indikator prekoracenja u tabeli (crveno). Status zakljucan na Ceka delove dok ima potrazivanih. Garancija: kalendar sa default vrednoscu. data-refresh podrzava vise selektora. Brisanje dela brise i potrazivane. Inline animation:none za AJAX refresh.
This commit is contained in:
2026-06-22 03:17:29 +02:00
parent c1ca619444
commit d4dcecb3ea
11 changed files with 475 additions and 155 deletions
+1
View File
@@ -359,6 +359,7 @@ func main() {
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/predracun", h.StampaPredracuna)
r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus)
r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija", h.AzurirajGaranciju)
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi", h.DodajDeloNalogu)
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga)
r.With(doz("servis.izmeni")).Post("/servis/{id}/radovi", h.DodajRadNalogu)
+10
View File
@@ -191,6 +191,7 @@ type ServisRepository interface {
Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error)
Izmeni(ctx context.Context, n *model.ServisniNalog) error
AzurirajStatus(ctx context.Context, id int64, status string) error
AzurirajGaranciju(ctx context.Context, id int64, garancijaDo *time.Time) error
Obrisi(ctx context.Context, id int64) error
SledeciBroj(ctx context.Context) (string, error)
}
@@ -210,9 +211,18 @@ type ProdajaRepository interface {
type ServisniDeloviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error)
Dodaj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (int64, error)
DohvatiArtikalID(ctx context.Context, deoID int64) (int64, error)
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
}
// ServisniPotrazivaniDeloviRepository definiše operacije nad delovima koji nedostaju
type ServisniPotrazivaniDeloviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error)
DodajIliUvecaj(ctx context.Context, nalogID, artikalID int64, kolicina int) (int64, error)
Obrisi(ctx context.Context, id int64) error
ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error
}
// ServisniRadoviRepository definiše operacije nad radovima (uslugama) na nalogu
type ServisniRadoviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error)
+22
View File
@@ -207,6 +207,28 @@ func (r *ServisRepo) AzurirajStatus(ctx context.Context, id int64, status string
return nil
}
// AzurirajGaranciju postavlja ili briše datum garancije na servisnom nalogu.
// garancijaDo == nil → bez garancije.
func (r *ServisRepo) AzurirajGaranciju(ctx context.Context, id int64, garancijaDo *time.Time) error {
if garancijaDo != nil {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET garancija_do = ? WHERE id = ?",
garancijaDo.Format("2006-01-02"), id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajGaranciju: %w", err)
}
} else {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET garancija_do = NULL WHERE id = ?", id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajGaranciju: %w", err)
}
}
return nil
}
// Obrisi briše servisni nalog po ID-u
func (r *ServisRepo) Obrisi(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM servisni_nalozi WHERE id = ?", id)
+12
View File
@@ -180,3 +180,15 @@ func (r *ServisniDeloviRepo) Obrisi(ctx context.Context, id int64, korisnikID *i
return nil
}
// DohvatiArtikalID vraća artikal_id za dati servisni deo (pre brisanja)
func (r *ServisniDeloviRepo) DohvatiArtikalID(ctx context.Context, deoID int64) (int64, error) {
var artikalID int64
err := r.db.QueryRowContext(ctx,
"SELECT artikal_id FROM servisni_delovi WHERE id = ?", deoID,
).Scan(&artikalID)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiArtikalID: %w", err)
}
return artikalID, nil
}
@@ -0,0 +1,107 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"ntech/internal/model"
)
// ServisniPotrazivaniDeloviRepo je SQLite implementacija ServisniPotrazivaniDeloviRepository
type ServisniPotrazivaniDeloviRepo struct {
db *sql.DB
}
// NoviServisniPotrazivaniDeloviRepo kreira novi repo
func NoviServisniPotrazivaniDeloviRepo(db *sql.DB) *ServisniPotrazivaniDeloviRepo {
return &ServisniPotrazivaniDeloviRepo{db: db}
}
// DohvatiZaNalog vraća sve potraživane delove za dati servisni nalog
func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT spd.id, spd.nalog_id, spd.artikal_id, spd.kolicina, spd.datum,
a.naziv
FROM servisni_potrazivani_delovi spd
JOIN artikli a ON a.id = spd.artikal_id
WHERE spd.nalog_id = ?
ORDER BY spd.datum`, nalogID)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DohvatiZaNalog: %w", err)
}
defer redovi.Close()
var rezultat []model.ServisniPotrazivaniDeo
for redovi.Next() {
var d model.ServisniPotrazivaniDeo
err := redovi.Scan(&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.Datum, &d.ArtikalNaziv)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DohvatiZaNalog: scan: %w", err)
}
rezultat = append(rezultat, d)
}
return rezultat, nil
}
// DodajIliUvecaj dodaje novi potraživani deo ili uvećava količinu ako već postoji.
// Vraća ID reda (novog ili postojećeg).
func (r *ServisniPotrazivaniDeloviRepo) DodajIliUvecaj(ctx context.Context, nalogID, artikalID int64, kolicina int) (int64, error) {
var postojeciID int64
var postojeciKol int
err := r.db.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ?",
nalogID, artikalID,
).Scan(&postojeciID, &postojeciKol)
if err == nil {
novaKol := postojeciKol + kolicina
_, err = r.db.ExecContext(ctx,
"UPDATE servisni_potrazivani_delovi SET kolicina = ? WHERE id = ?",
novaKol, postojeciID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DodajIliUvecaj: update: %w", err)
}
return postojeciID, nil
}
if errors.Is(err, sql.ErrNoRows) {
rez, err := r.db.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina) VALUES (?, ?, ?)",
nalogID, artikalID, kolicina,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DodajIliUvecaj: insert: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DodajIliUvecaj: last insert id: %w", err)
}
return id, nil
}
return 0, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DodajIliUvecaj: proveri: %w", err)
}
// Obrisi uklanja potraživani deo po ID-u
func (r *ServisniPotrazivaniDeloviRepo) Obrisi(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM servisni_potrazivani_delovi WHERE id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.Obrisi: %w", err)
}
return nil
}
// ObrisiZaArtikal briše sve potraživane delove za dati artikal na datom nalogu
func (r *ServisniPotrazivaniDeloviRepo) ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error {
_, err := r.db.ExecContext(ctx,
"DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ?",
nalogID, artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ObrisiZaArtikal: %w", err)
}
return nil
}
+61 -58
View File
@@ -17,38 +17,39 @@ import (
// Handler drži zavisnosti koje su potrebne svim handlerima
type Handler struct {
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
UslugeRepo db.UslugaRepository
TroskoviRepo db.TrosakRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
ServisniRadoviRepo db.ServisniRadoviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository
RezervniKodoviRepo db.RezervniKodoviRepository
PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
PdvStopeRepo db.PdvStopaRepository
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
Verzija string
JelDemo bool
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
TemplatesFS fs.FS
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo)
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
UslugeRepo db.UslugaRepository
TroskoviRepo db.TrosakRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
ServisniRadoviRepo db.ServisniRadoviRepository
ServisniPotrazivaniDeloviRepo db.ServisniPotrazivaniDeloviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository
RezervniKodoviRepo db.RezervniKodoviRepository
PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
PdvStopeRepo db.PdvStopaRepository
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
Verzija string
JelDemo bool
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
TemplatesFS fs.FS
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo)
// mu štiti DB i sve repozitorijume od zamene u letu (obnova backupa).
// Zahtevi drže deljeno (read) zaključavanje preko ZakljucajCitanje, a obnova
@@ -84,32 +85,33 @@ func (h *Handler) SaBazom(fn func(*sql.DB)) {
// totpKljuc je 32-bajtni ključ za šifrovanje TOTP tajni u mirovanju.
func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
return &Handler{
DB: baza,
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
UslugeRepo: sqlite.NoviUslugaRepo(baza),
TroskoviRepo: sqlite.NoviTrosakRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
ServisniRadoviRepo: sqlite.NoviServisniRadoviRepo(baza),
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
RezervniKodoviRepo: sqlite.NoviRezervniKodoviRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza),
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
DB: baza,
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
UslugeRepo: sqlite.NoviUslugaRepo(baza),
TroskoviRepo: sqlite.NoviTrosakRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
ServisniRadoviRepo: sqlite.NoviServisniRadoviRepo(baza),
ServisniPotrazivaniDeloviRepo: sqlite.NoviServisniPotrazivaniDeloviRepo(baza),
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
RezervniKodoviRepo: sqlite.NoviRezervniKodoviRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza),
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
}
}
@@ -128,6 +130,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.ServisRepo = sqlite.NoviServisRepo(novaDB)
h.ServisniDeloviRepo = sqlite.NoviServisniDeloviRepo(novaDB)
h.ServisniRadoviRepo = sqlite.NoviServisniRadoviRepo(novaDB)
h.ServisniPotrazivaniDeloviRepo = sqlite.NoviServisniPotrazivaniDeloviRepo(novaDB)
h.MagacinskePromeneRepo = sqlite.NoviMagacinskePromeneRepo(novaDB)
h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB)
h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB, h.totpKljuc)
+166 -52
View File
@@ -43,19 +43,20 @@ type PodaciFormeNaloga struct {
// PodaciDetaljiNaloga su podaci za pregled jednog servisnog naloga
type PodaciDetaljiNaloga struct {
model.PodaciStranice
Nalog model.ServisniNalog
KlijentNaziv string
TehnicarNaziv string
ServisniDelovi []model.ServisniDeoSaArtiklom
ServisniRadovi []model.ServisniRad
Artikli []model.ArtikalSaKategorijom
Usluge []model.Usluga
Sacuvano bool
UkupnoDelovi float64
UkupnoRadovi float64
UkupnoSve float64
PreostaloSve float64
SviStatusi []string
Nalog model.ServisniNalog
KlijentNaziv string
TehnicarNaziv string
ServisniDelovi []model.ServisniDeoSaArtiklom
ServisniRadovi []model.ServisniRad
Artikli []model.ArtikalSaKategorijom
Usluge []model.Usluga
Sacuvano bool
UkupnoDelovi float64
UkupnoRadovi float64
UkupnoSve float64
PreostaloSve float64
ZakljucanStatus bool // onemogući promenu statusa dok ima potraživanih delova
SviStatusi []string
}
// Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa
@@ -424,7 +425,7 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
}
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil {
slog.Error("greška pri učitavanju delova", "error", err)
}
@@ -463,24 +464,33 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
preostaloSve = 0
}
zakljucanStatus := false
for _, d := range delovi {
if d.Potrazivano > 0 {
zakljucanStatus = true
break
}
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Detalji naloga"
podaci := PodaciDetaljiNaloga{
PodaciStranice: ps,
Nalog: *nalog,
KlijentNaziv: klijentNaziv,
TehnicarNaziv: tehnicarNaziv,
ServisniDelovi: delovi,
ServisniRadovi: radovi,
Artikli: artikli,
Usluge: usluge,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
UkupnoDelovi: ukupnoDelovi,
UkupnoRadovi: ukupnoRadovi,
UkupnoSve: ukupnoSve,
PreostaloSve: preostaloSve,
SviStatusi: model.SviStatusi,
PodaciStranice: ps,
Nalog: *nalog,
KlijentNaziv: klijentNaziv,
TehnicarNaziv: tehnicarNaziv,
ServisniDelovi: delovi,
ServisniRadovi: radovi,
Artikli: artikli,
Usluge: usluge,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
UkupnoDelovi: ukupnoDelovi,
UkupnoRadovi: ukupnoRadovi,
UkupnoSve: ukupnoSve,
PreostaloSve: preostaloSve,
ZakljucanStatus: zakljucanStatus,
SviStatusi: model.SviStatusi,
}
h.renderujTemplate(w, "servis_detalji", podaci)
@@ -525,34 +535,88 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return
}
// prepoznaj prekoračenje magacinskog stanja PRE dodavanja (posle dodavanja
// lager se smanji); deo se svejedno ugrađuje (dozvoljen backorder/minus lager)
prekoraceno := false
// proveri stanje na magacinu: ako tražimo više nego što ima, ugrađujemo samo
// ono što je na stanju, a razliku beležimo u potraživane delove (ne skida se
// sa lagera dok ne stigne); lager NIKAD ne ide u minus
ugradjeno := kolicina
nedostaje := 0
imeArtikla := ""
if art, e := h.Artikli.DohvatiID(r.Context(), artikalID); e == nil && art != nil {
prekoraceno = kolicina > art.Kolicina
imeArtikla = art.Naziv
if kolicina > art.Kolicina {
ugradjeno = art.Kolicina
nedostaje = kolicina - art.Kolicina
}
}
if _, err := h.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID); err != nil {
slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
// ugrađujemo samo ono što fizički imamo (ako ima > 0)
if ugradjeno > 0 {
if _, err := h.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, ugradjeno, cena, &k.ID); err != nil {
slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
}
}
// prekoračenje → nalog automatski u „Čeka delove" (signal da treba nabavka)
if prekoraceno {
// višak → potraživani delovi (ne skidamo sa lagera)
if nedostaje > 0 {
if _, err := h.ServisniPotrazivaniDeloviRepo.DodajIliUvecaj(r.Context(), nalogID, artikalID, nedostaje); err != nil {
slog.Error("greška pri beleženju potraživanog dela", "error", err)
}
// automatski prebaci nalog u „Čeka delove"
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.")
if ugradjeno > 0 {
middleware.SetFlash(w, r, h.DB, "greska",
"Ugrađeno "+strconv.Itoa(ugradjeno)+" kom. artikla „"+imeArtikla+
"\", nedostaje još "+strconv.Itoa(nedostaje)+" — nalog je prebačen u „Čeka delove\".")
} else {
middleware.SetFlash(w, r, h.DB, "greska",
"Nema „"+imeArtikla+"\" na stanju — potrebno "+strconv.Itoa(nedostaje)+
" kom. Nalog je prebačen u „Čeka delove\".")
}
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// deloviSaPotrazivanima vraća ugrađene delove spojene sa potraživanima (ista tabela).
// Potraživani delovi bez ugrađenog (stanje bilo 0) se dodaju sa Kolicina=0.
func (h *Handler) deloviSaPotrazivanima(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error) {
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(ctx, nalogID)
if err != nil {
return nil, err
}
potrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(ctx, nalogID)
if err != nil {
return delovi, nil // ne prekidamo ako potraživani nisu dostupni
}
potrMap := make(map[int64]int)
for _, p := range potrazivani {
potrMap[p.ArtikalID] += p.Kolicina
}
for i := range delovi {
if kol, ok := potrMap[delovi[i].ArtikalID]; ok {
delovi[i].Potrazivano = kol
delete(potrMap, delovi[i].ArtikalID)
}
}
for artikalID, kol := range potrMap {
naziv := ""
if art, e := h.Artikli.DohvatiID(ctx, artikalID); e == nil && art != nil {
naziv = art.Naziv
}
delovi = append(delovi, model.ServisniDeoSaArtiklom{
ArtikalNaziv: naziv,
ServisniDeo: model.ServisniDeo{ArtikalID: artikalID},
Potrazivano: kol,
})
}
return delovi, nil
}
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga
func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "servis.izmeni")
@@ -572,6 +636,13 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
return
}
// obriši i potraživane delove za isti artikal (inače ostaju blokirani u tabeli)
if artikalID, err := h.ServisniDeloviRepo.DohvatiArtikalID(r.Context(), deoID); err == nil {
if err := h.ServisniPotrazivaniDeloviRepo.ObrisiZaArtikal(r.Context(), nalogID, artikalID); err != nil {
slog.Error("greška pri brisanju potraživanih delova", "error", err)
}
}
if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
slog.Error("greška pri brisanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.")
@@ -854,7 +925,7 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
return
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
return
@@ -945,7 +1016,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
return
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
return
@@ -1066,7 +1137,7 @@ func (h *Handler) StampaPredracuna(w http.ResponseWriter, r *http.Request) {
return
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
return
@@ -1187,17 +1258,14 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
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
// zabrana izlaska iz „Čeka delove" dok postoje potraživani delovi —
// prvo nabaviti delove, pa obrisati iz potraživanih (ili kroz nabavku)
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 {
potrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id)
if err == nil && len(potrazivani) > 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.")
"Nalog ne može da napusti „Čeka delove\" dok ima delova koji nedostaju. Obrišite ih iz tabele ili dopunite zalihe.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
@@ -1211,6 +1279,52 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// AzurirajGaranciju ažurira datum garancije na servisnom nalogu
func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
var garancijaDo *time.Time
if r.FormValue("bez_garancije") != "1" {
if gd := strings.TrimSpace(r.FormValue("garancija_do")); gd != "" {
t, err := time.Parse("2006-01-02", gd)
if err != nil {
http.Error(w, "Neispravan datum garancije", http.StatusBadRequest)
return
}
garancijaDo = &t
} else {
// odštiklirano „bez garancije" bez izabranog datuma → vrati default
nalog, err := h.ServisRepo.DohvatiID(r.Context(), id)
if err != nil {
slog.Error("greška pri dohvatanju naloga za garanciju", "id", id, "error", err)
} else {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
slog.Error("greška pri učitavanju podešavanja za garanciju", "error", err)
} else {
garancijaDo = defaultGarancija(nalog.DatumPrijema, podesavanja)
}
}
}
}
if err := h.ServisRepo.AzurirajGaranciju(r.Context(), id, garancijaDo); err != nil {
slog.Error("greška pri ažuriranju garancije", "id", id, "error", err)
http.Error(w, "Greška pri ažuriranju garancije", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
type PodaciJavnogStatusa struct {
Nalog model.ServisniNalog
+13
View File
@@ -68,6 +68,19 @@ func (d ServisniDeo) Ukupno() float64 {
type ServisniDeoSaArtiklom struct {
ServisniDeo
ArtikalNaziv string
Potrazivano int // komada koji nedostaju (iz servisni_potrazivani_delovi), 0 ako nema
}
// ServisniPotrazivaniDeo beleži artikle koji nedostaju za servisni nalog —
// količina koja se traži a nije na stanju; ne skida se sa lagera dok ne stigne
type ServisniPotrazivaniDeo struct {
ID int64
NalogID int64
ArtikalID int64
Kolicina int
Datum time.Time
// za prikaz:
ArtikalNaziv string
}
// ServisniRad predstavlja jednu stavku rada (uslugu) na servisnom nalogu.
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS servisni_potrazivani_delovi (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nalog_id INTEGER NOT NULL REFERENCES servisni_nalozi(id),
artikal_id INTEGER NOT NULL REFERENCES artikli(id),
kolicina INTEGER NOT NULL DEFAULT 0,
datum TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+53 -15
View File
@@ -47,12 +47,16 @@
{{template "status-badge-detalji" .Nalog.Status}}
<form method="post" action="/servis/{{.Nalog.ID}}/status" style="display:flex;align-items:center;gap:6px;" data-refresh="#nalog-zaglavlje">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<select name="status" style="font-size:13px;padding:4px 8px;border-radius:6px;border:0.5px solid var(--ivica);background:var(--kartica-pozadina);color:var(--tekst-glavni);cursor:pointer;">
<select name="status" style="font-size:13px;padding:4px 8px;border-radius:6px;border:0.5px solid var(--ivica);background:var(--kartica-pozadina);color:var(--tekst-glavni);cursor:pointer;"{{if .ZakljucanStatus}} disabled title="Status zaključan dok ima delova koji nedostaju"{{end}}>
{{range .SviStatusi}}
<option value="{{.}}"{{if eq . $.Nalog.Status}} selected{{end}}>{{.}}</option>
{{end}}
</select>
{{if .ZakljucanStatus}}
<span style="font-size:12px;color:#dc2626;">🔒 Status zaključan — obrišite delove koji nedostaju</span>
{{else}}
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:4px 12px;">Promeni</button>
{{end}}
</form>
</div>
</div>
@@ -86,12 +90,30 @@
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</div>
</div>
{{end}}
{{if .Nalog.GarancijaDo}}
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija do</div>
<div style="font-size:14px;color:#16a34a;font-weight:500;">{{.Nalog.GarancijaDo.Format "02.01.2006."}}</div>
{{if index .Dozvole "servis.izmeni"}}
<form method="post" action="/servis/{{.Nalog.ID}}/garancija" data-refresh="#nalog-zaglavlje" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<span style="font-size:14px;{{if .Nalog.GarancijaDo}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}">
{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "02.01.2006."}}{{else}}—{{end}}
</span>
<input type="date" name="garancija_do" id="garancija-datum" value="{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "2006-01-02"}}{{end}}"
onchange="var cb=this.form.querySelector('[name=bez_garancije]');if(cb)cb.checked=false;this.form.requestSubmit()"
style="position:absolute;opacity:0;width:0;height:0;pointer-events:none;" tabindex="-1">
<button type="button" onclick="document.getElementById('garancija-datum').showPicker()"
style="background:none;border:0.5px solid var(--ivica);border-radius:6px;cursor:pointer;padding:4px 6px;font-size:14px;line-height:1;" title="Izmeni datum garancije">📅</button>
<label style="font-size:12px;color:var(--tekst-sporedni);cursor:pointer;display:flex;align-items:center;gap:3px;">
<input type="checkbox" name="bez_garancije" value="1" onchange="this.form.requestSubmit()" style="cursor:pointer;"
{{if not .Nalog.GarancijaDo}}checked{{end}}> bez garancije
</label>
</form>
{{else}}
<div style="font-size:14px;{{if .Nalog.GarancijaDo}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}">
{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "02.01.2006."}}{{else}}—{{end}}
</div>
{{end}}
</div>
{{end}}
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Klijent</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">
@@ -245,7 +267,7 @@
<!-- forma za dodavanje dela -->
{{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/delovi" style="margin-bottom:16px;" data-refresh="#delovi-spisak">
<form method="POST" action="/servis/{{.Nalog.ID}}/delovi" style="margin-bottom:16px;" data-refresh="#delovi-spisak, #nalog-zaglavlje">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div style="margin-bottom:10px;">
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Kategorija</label>
@@ -292,14 +314,20 @@
</thead>
<tbody>
{{range .ServisniDelovi}}
<tr style="border-bottom:0.5px solid var(--ivica);">
<tr data-artikal-id="{{.ArtikalID}}" data-kolicina="{{.Kolicina}}" data-potrazivano="{{.Potrazivano}}" style="border-bottom:0.5px solid var(--ivica);{{if .Potrazivano}}background:rgba(220,38,38,0.06);{{end}}">
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
<td style="padding:9px 10px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{dinari .CenaKomada}} din</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
<td style="padding:9px 10px;text-align:center;font-size:14px;{{if .Potrazivano}}color:#dc2626;font-weight:600;{{else}}color:var(--tekst-glavni);{{end}}">
{{.Kolicina}}{{if .Potrazivano}}<span style="font-weight:400;"> + {{.Potrazivano}} na čekanju</span>{{end}}
</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">
{{if .Kolicina}}{{dinari .CenaKomada}} din{{else}}—{{end}}
</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}}
</td>
<td style="padding:9px 10px;text-align:center;">
{{if index $.Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#delovi-spisak">
{{if and .ID (index $.Dozvole "servis.izmeni")}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#delovi-spisak, #nalog-zaglavlje">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit"
data-potvrda="Ukloniti deo '{{.ArtikalNaziv}}'?"
@@ -367,11 +395,21 @@
var stanjeIzabrano = 0;
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)
// boji „U magacinu" i polje količine crveno kada ukupno (postojeće u tabeli
// + nova količina) prelazi stanje; čita direktno iz DOM-a tabele jer se ona
// osvežava AJAX-om, pa JSON varijabla zastareva
function osveziPrekoracenje() {
var preko = (parseInt(kol && kol.value, 10) || 0) > stanjeIzabrano && artikalSel.value;
var novaKol = parseInt(kol && kol.value, 10) || 0;
var postojeciKol = 0;
if (artikalSel.value) {
var aid = artikalSel.value;
var redovi = document.querySelectorAll('#delovi-spisak tr[data-artikal-id="' + aid + '"]');
redovi.forEach(function (tr) {
postojeciKol += (parseInt(tr.getAttribute('data-kolicina'), 10) || 0) +
(parseInt(tr.getAttribute('data-potrazivano'), 10) || 0);
});
}
var preko = (postojeciKol + novaKol) > stanjeIzabrano && artikalSel.value;
if (umagacinu) umagacinu.classList.toggle('prekoraceno', preko);
if (kol) kol.classList.toggle('prekoraceno', preko);
}
+23 -30
View File
@@ -391,36 +391,29 @@
.then(function (rr) { return rr.text(); })
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var novi = doc.querySelector(ciljSel);
var stari = document.querySelector(ciljSel);
if (novi && stari) {
// bez-anim spречava treptaj re-animacije kartica pri zameni
novi.classList.add('bez-anim');
var sc = document.scrollingElement || document.documentElement;
var syDoc = sc ? sc.scrollTop : 0;
var gsStari = document.getElementById('sadrzaj');
var syGs = gsStari ? gsStari.scrollTop : 0;
stari.replaceWith(novi);
void novi.offsetHeight;
novi.classList.remove('bez-anim');
// replaceWith ne pokreće <script> iz fragmenta re-izvrši ih
// ručno (npr. da se combobox/pretrage ponovo povežu)
novi.querySelectorAll('script').forEach(function (old) {
var s = document.createElement('script');
if (old.src) s.src = old.src; else s.textContent = old.textContent;
old.parentNode.replaceChild(s, old);
});
if (sc) sc.scrollTop = syDoc;
var gsNovi = document.getElementById('sadrzaj');
if (gsNovi) gsNovi.scrollTop = syGs;
// re-veži event handlere (AJAX submit, potvrda brisanja...)
// na sveže umetnute elemente — inače bi nove forme išle
// punim POST-om (skok na vrh), a × bez potvrde
ntechInicijalizuj();
}
// forma koja nije deo osveženog dela ostaje u DOM-u —
// isprazni je za sledeći unos (za one koje osvežavaju sebe
// ovo je bezuspešno jer su zamenjene, što je u redu)
// podržava više selektora razdvojenih zarezom (npr. "#a, #b")
var sc = document.scrollingElement || document.documentElement;
var syDoc = sc ? sc.scrollTop : 0;
var gsStari = document.getElementById('sadrzaj');
var syGs = gsStari ? gsStari.scrollTop : 0;
ciljSel.split(',').forEach(function (sel) {
sel = sel.trim();
var novi = doc.querySelector(sel);
var stari = document.querySelector(sel);
if (novi && stari) {
novi.style.animation = 'none';
stari.replaceWith(novi);
novi.querySelectorAll('script').forEach(function (old) {
var s = document.createElement('script');
if (old.src) s.src = old.src; else s.textContent = old.textContent;
old.parentNode.replaceChild(s, old);
});
}
});
if (sc) sc.scrollTop = syDoc;
var gsNovi = document.getElementById('sadrzaj');
if (gsNovi) gsNovi.scrollTop = syGs;
ntechInicijalizuj();
if (document.body.contains(f)) f.reset();
if (btn) btn.disabled = false;
})