From 15c44dd2d30eac883cbf79007555f54593bb56ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 25 Jun 2026 01:39:56 +0200 Subject: [PATCH] Servis: garantni list, radovi na otpremnici, selektivno prihvatanje predloga po stavkama --- cmd/ntech/main.go | 2 + internal/db/repository.go | 6 + internal/db/sqlite/servisni_delovi.go | 68 +++++++++++ internal/db/sqlite/servisni_radovi.go | 48 ++++++++ internal/handler/servis.go | 111 ++++++++++++++++++ web/templates/stranice/servis_detalji.html | 38 +++++- web/templates/stranice/servis_otpremnica.html | 27 +++++ web/templates/stranice/servis_revers.html | 80 ++++++++++++- .../stranice/servis_status_javni.html | 42 ++++--- 9 files changed, 404 insertions(+), 18 deletions(-) diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 61e22ed..96c6d74 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -241,6 +241,7 @@ func main() { r.Get("/status/{token}", h.ServisJavniStatus) r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati) r.Post("/status/{token}/odbij", h.ServisJavniOdbij) + r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano) // zaštićene rute — zahtevaju prijavljenog korisnika r.Group(func(r chi.Router) { @@ -375,6 +376,7 @@ func main() { r.With(doz("servis.izmeni")).Post("/servis/{id}/uradjeno", h.SacuvajUradjeno) r.With(doz("servis.izmeni")).Post("/servis/{id}/cena-dijagnostike", h.AzurirajCenaDijagnostike) r.With(doz("servis.izmeni")).Post("/servis/{id}/odbij-popravku", h.OdbijPopravku) + r.With(doz("servis.izmeni")).Post("/servis/{id}/prihvati-odabrano", h.PrihvatiOdabraniPredlog) r.With(doz("servis.izmeni")).Post("/servis/{id}/obrisi-komentar-klijenta", h.ObrisiKomentarKlijenta) 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) diff --git a/internal/db/repository.go b/internal/db/repository.go index 8f2c41e..063c76e 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -232,6 +232,9 @@ type ServisniDeloviRepository interface { PrihvatiPredlozene(ctx context.Context, nalogID int64) error // ObrisiPredlozene briše sve predložene delove sa naloga (klijent odbio predlog) ObrisiPredlozene(ctx context.Context, nalogID int64) error + // PrihvatiOdabranePoArtiklu selektivno prihvata predlozene delove po artikal_id; + // prihvaćeni idu kroz normalnu ugradnju (skidaju sa lagera), ostali se odbacuju. + PrihvatiOdabranePoArtiklu(ctx context.Context, nalogID int64, artikalIDs []int64) error } // ServisniPotrazivaniDeloviRepository definiše operacije nad delovima koji nedostaju @@ -255,6 +258,9 @@ type ServisniRadoviRepository interface { PrihvatiPredlozene(ctx context.Context, nalogID int64) error // ObrisiPredlozene briše sve predložene radove sa naloga (klijent odbio predlog) ObrisiPredlozene(ctx context.Context, nalogID int64) error + // PrihvatiOdabrane selektivno prihvata predlozene radove po ID-u; + // prihvaćeni dobijaju predlozeno=0, ostali predlozeni se brišu. + PrihvatiOdabrane(ctx context.Context, nalogID int64, ids []int64) error } // MagacinskePromeneRepository definiše operacije nad revizijskim tragom magacina diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index 8da3311..a5d2270 100644 --- a/internal/db/sqlite/servisni_delovi.go +++ b/internal/db/sqlite/servisni_delovi.go @@ -315,6 +315,74 @@ func (r *ServisniDeloviRepo) ObrisiPredlozene(ctx context.Context, nalogID int64 return nil } +// PrihvatiOdabranePoArtiklu prihvata selektivno predložene delove: artikalIDs su prihvaćeni +// (idu kroz normalnu ugradnju/lager), svi ostali predlozeni se brišu bez povraćaja lagera. +func (r *ServisniDeloviRepo) PrihvatiOdabranePoArtiklu(ctx context.Context, nalogID int64, artikalIDs []int64) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiOdabranePoArtiklu: begin tx: %w", err) + } + defer tx.Rollback() + + // Dohvati sve predložene potraživane + redovi, err := tx.QueryContext(ctx, + "SELECT id, artikal_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 1", + nalogID, + ) + if err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiOdabranePoArtiklu: dohvati: %w", err) + } + type stavka struct { + id int64 + artikalID int64 + kolicina int + cena float64 + } + var sve []stavka + for redovi.Next() { + var s stavka + if err := redovi.Scan(&s.id, &s.artikalID, &s.kolicina, &s.cena); err != nil { + redovi.Close() + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiOdabranePoArtiklu: scan: %w", err) + } + sve = append(sve, s) + } + redovi.Close() + if err := redovi.Err(); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiOdabranePoArtiklu: rows: %w", err) + } + + prihvaceni := make(map[int64]bool, len(artikalIDs)) + for _, id := range artikalIDs { + prihvaceni[id] = true + } + + for _, s := range sve { + if prihvaceni[s.artikalID] { + if _, _, err := ugradiIliPotrazujTx(ctx, tx, nalogID, s.artikalID, s.kolicina, s.cena, nil, false); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiOdabranePoArtiklu: ugradi: %w", err) + } + } + } + + // Briši sve predlozene potraživane (prihvaćeni su prebačeni, odbijeni se odbacuju) + if _, err := tx.ExecContext(ctx, + "DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID, + ); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiOdabranePoArtiklu: obrisi: %w", err) + } + if _, err := tx.ExecContext(ctx, + "DELETE FROM servisni_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID, + ); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiOdabranePoArtiklu: obrisi delove: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiOdabranePoArtiklu: commit: %w", err) + } + 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 diff --git a/internal/db/sqlite/servisni_radovi.go b/internal/db/sqlite/servisni_radovi.go index bf8a8ed..b1d92df 100644 --- a/internal/db/sqlite/servisni_radovi.go +++ b/internal/db/sqlite/servisni_radovi.go @@ -89,6 +89,54 @@ func (r *ServisniRadoviRepo) ObrisiPredlozene(ctx context.Context, nalogID int64 return nil } +// PrihvatiOdabrane prihvata selektivno predložene radove: ids su prihvaćeni (predlozeno→0), +// ostali predlozeni se brišu. +func (r *ServisniRadoviRepo) PrihvatiOdabrane(ctx context.Context, nalogID int64, ids []int64) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("ntech: ServisniRadoviRepo.PrihvatiOdabrane: begin tx: %w", err) + } + defer tx.Rollback() + + prihvaceni := make(map[int64]bool, len(ids)) + for _, id := range ids { + prihvaceni[id] = true + } + + // Dohvati sve predlozene radove + redovi, err := tx.QueryContext(ctx, "SELECT id FROM servis_radovi WHERE nalog_id = ? AND predlozeno = 1", nalogID) + if err != nil { + return fmt.Errorf("ntech: ServisniRadoviRepo.PrihvatiOdabrane: dohvati: %w", err) + } + var sviIDs []int64 + for redovi.Next() { + var id int64 + if err := redovi.Scan(&id); err != nil { + redovi.Close() + return fmt.Errorf("ntech: ServisniRadoviRepo.PrihvatiOdabrane: scan: %w", err) + } + sviIDs = append(sviIDs, id) + } + redovi.Close() + + for _, id := range sviIDs { + if prihvaceni[id] { + if _, err := tx.ExecContext(ctx, "UPDATE servis_radovi SET predlozeno = 0 WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: ServisniRadoviRepo.PrihvatiOdabrane: update: %w", err) + } + } else { + if _, err := tx.ExecContext(ctx, "DELETE FROM servis_radovi WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: ServisniRadoviRepo.PrihvatiOdabrane: delete: %w", err) + } + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("ntech: ServisniRadoviRepo.PrihvatiOdabrane: commit: %w", err) + } + return nil +} + // Obrisi uklanja jednu stavku rada sa naloga func (r *ServisniRadoviRepo) Obrisi(ctx context.Context, id int64) error { if _, err := r.db.ExecContext(ctx, "DELETE FROM servis_radovi WHERE id = ?", id); err != nil { diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 0a9598a..3581e7f 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -1347,6 +1347,7 @@ func (h *Handler) StampaRadnogNaloga(w http.ResponseWriter, r *http.Request) { // PodaciOtpremnice su podaci za otpremnicu pri preuzimanju uređaja type PodaciOtpremnice struct { Nalog model.ServisniNalog + Radovi []model.ServisniRad ServisniDelovi []model.ServisniDeoSaArtiklom UkupnoDelovi float64 PreostaloSve float64 @@ -1379,6 +1380,12 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) { return } + radovi, err := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id) + if err != nil { + http.Error(w, "Greška pri učitavanju radova", http.StatusInternalServerError) + return + } + delovi, err := h.deloviSaPotrazivanima(r.Context(), id) if err != nil { http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError) @@ -1452,6 +1459,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) { h.renderujStandalone(w, "servis_otpremnica", PodaciOtpremnice{ Nalog: *nalog, + Radovi: radovi, ServisniDelovi: delovi, UkupnoDelovi: ukupnoDelovi, PreostaloSve: preostaloSve, @@ -2359,6 +2367,109 @@ func (h *Handler) ServisJavniPrihvati(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/status/"+token, http.StatusSeeOther) } +// ServisJavniOdlukaOdabrano obrađuje selektivno odobrenje predloga sa javne status stranice. +// Klijent čekira pojedinačne stavke; prihvaćene idu u ugrađene, ostale se brišu. +func (h *Handler) ServisJavniOdlukaOdabrano(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + if token == "" { + http.NotFound(w, r) + return + } + + nalog, err := h.ServisRepo.DohvatiJavniToken(r.Context(), token) + if err != nil { + http.NotFound(w, r) + return + } + + if nalog.Status == model.StatusPreuzeto { + http.NotFound(w, r) + return + } + + if nalog.OdlukaKlijenta != "" { + http.Redirect(w, r, "/status/"+token, http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Neispravan zahtev.", http.StatusBadRequest) + return + } + + radIDs := parsInt64s(r.Form["rad_id"]) + artikalIDs := parsInt64s(r.Form["artikal_id"]) + + if err := h.ServisniRadoviRepo.PrihvatiOdabrane(r.Context(), nalog.ID, radIDs); err != nil { + slog.Error("greška pri selektivnom prihvatanju radova", "error", err, "nalog_id", nalog.ID) + http.Error(w, "Greška pri obradi predloga.", http.StatusInternalServerError) + return + } + if err := h.ServisniDeloviRepo.PrihvatiOdabranePoArtiklu(r.Context(), nalog.ID, artikalIDs); err != nil { + slog.Error("greška pri selektivnom prihvatanju delova", "error", err, "nalog_id", nalog.ID) + http.Error(w, "Greška pri obradi predloga.", http.StatusInternalServerError) + return + } + + komentar := strings.TrimSpace(r.FormValue("komentar")) + odluka := "prihvaceno" + if len(radIDs) == 0 && len(artikalIDs) == 0 { + odluka = "odbijeno" + } + if err := h.ServisRepo.SacuvajOdlukuKlijenta(r.Context(), nalog.ID, odluka, komentar); err != nil { + slog.Error("greška pri čuvanju odluke klijenta", "error", err, "nalog_id", nalog.ID) + } + + http.Redirect(w, r, "/status/"+token, http.StatusSeeOther) +} + +// PrihvatiOdabraniPredlog je interni handler za selektivno prihvatanje predloga +// (serviser štiklira stavke u detaljima naloga i klika "Prihvati odabrano"). +func (h *Handler) PrihvatiOdabraniPredlog(w http.ResponseWriter, r *http.Request) { + _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni") + if !ok { + return + } + + nalogID, 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, "Neispravan zahtev.", http.StatusBadRequest) + return + } + + radIDs := parsInt64s(r.Form["rad_id"]) + artikalIDs := parsInt64s(r.Form["artikal_id"]) + + if err := h.ServisniRadoviRepo.PrihvatiOdabrane(r.Context(), nalogID, radIDs); err != nil { + slog.Error("greška pri selektivnom prihvatanju radova", "error", err, "nalog_id", nalogID) + http.Error(w, "Greška pri obradi predloga.", http.StatusInternalServerError) + return + } + if err := h.ServisniDeloviRepo.PrihvatiOdabranePoArtiklu(r.Context(), nalogID, artikalIDs); err != nil { + slog.Error("greška pri selektivnom prihvatanju delova", "error", err, "nalog_id", nalogID) + http.Error(w, "Greška pri obradi predloga.", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) +} + +// parsInt64s konvertuje listu string vrednosti u []int64, preskačući neispravne. +func parsInt64s(vals []string) []int64 { + result := make([]int64, 0, len(vals)) + for _, v := range vals { + if id, err := strconv.ParseInt(v, 10, 64); err == nil && id > 0 { + result = append(result, id) + } + } + return result +} + // ObrisiKomentarKlijenta briše poruku klijenta sa naloga func (h *Handler) ObrisiKomentarKlijenta(w http.ResponseWriter, r *http.Request) { _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni") diff --git a/web/templates/stranice/servis_detalji.html b/web/templates/stranice/servis_detalji.html index fc9f34a..da962c0 100644 --- a/web/templates/stranice/servis_detalji.html +++ b/web/templates/stranice/servis_detalji.html @@ -718,7 +718,43 @@ {{end}} {{/* kraj stavke-blok */}} - + + {{if and (index .Dozvole "servis.izmeni") (or .PredlozeniRadovi .PredlozeniDelovi)}} +
+
+ Predložene stavke + Odštikliraj šta ne prihvataš +
+
+ + {{if .PredlozeniRadovi}} +
Radovi
+ {{range .PredlozeniRadovi}} + + {{end}} + {{end}} + {{if .PredlozeniDelovi}} +
Delovi
+ {{range .PredlozeniDelovi}} + + {{end}} + {{end}} + +
+
+ {{end}}
diff --git a/web/templates/stranice/servis_otpremnica.html b/web/templates/stranice/servis_otpremnica.html index 5aecc55..a17b57f 100644 --- a/web/templates/stranice/servis_otpremnica.html +++ b/web/templates/stranice/servis_otpremnica.html @@ -164,6 +164,33 @@
{{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}{{end}}
+ + {{if .Radovi}} +
+
Izvršeni radovi
+ + + + + + + + + + + {{range .Radovi}} + + + + + + + {{end}} + +
Opis radaKol.Cena/komUkupno
{{.Naziv}}{{printf "%.0f" .Kolicina}}{{dinari .CenaKomada}} din{{dinari .Ukupno}} din
+
+ {{end}} + {{if .ServisniDelovi}}
diff --git a/web/templates/stranice/servis_revers.html b/web/templates/stranice/servis_revers.html index 16d55ee..0e10f51 100644 --- a/web/templates/stranice/servis_revers.html +++ b/web/templates/stranice/servis_revers.html @@ -54,14 +54,18 @@ /* napomena na dnu */ .dno-info { margin-top: 20px; font-size: 11px; color: #888; text-align: center; } + @page { size: A4; margin: 12mm 14mm 18mm; } @media print { body { font-size: 12px; } .strana { padding: 16px 20px; max-width: 100%; } .dugme-stampa { display: none !important; } - @page { size: A4; margin: 12mm 14mm; } /* uslovi servisa (sa potpisima) idu na novu stranicu */ .uslovi-strana { break-before: page; } + .stampa-broj-strane { display: block; text-align: center; font-size: 9px; color: #888; margin-top: 16px; } + .stampa-samo-ekran { display: none !important; } } + .stampa-broj-strane { display: none; } + .stampa-samo-ekran { display: block; } .dugme-stampa { position: fixed; bottom: 24px; right: 24px; background: #111; color: #fff; border: none; padding: 12px 22px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .dugme-stampa:hover { background: #333; } @@ -202,5 +206,79 @@ + + diff --git a/web/templates/stranice/servis_status_javni.html b/web/templates/stranice/servis_status_javni.html index 749a372..8a02445 100644 --- a/web/templates/stranice/servis_status_javni.html +++ b/web/templates/stranice/servis_status_javni.html @@ -261,18 +261,33 @@ Serviser predlaže sledeće radove i delove. Pogledajte i javite nam da li ste saglasni.
+
+ {{if .PredlozeniRadovi}} +
Radovi
{{range .PredlozeniRadovi}} -
- {{.Naziv}}{{if gt .Kolicina 1.0}} ×{{.Kolicina}}{{end}} - {{dinari .Ukupno}} din -
+ + {{end}} {{end}} + {{if .PredlozeniDelovi}} +
Delovi i materijal
{{range .PredlozeniDelovi}} -
- {{.ArtikalNaziv}}{{if gt .Kolicina 1}} ×{{.Kolicina}}{{end}} - {{dinari .Ukupno}} din -
+ + {{end}} {{end}}
@@ -280,14 +295,9 @@ {{dinari .UkupnoPredlog}} din
- - - -
- - -
+ +
{{end}}