Servis predlog delovi: lager integracija, brisanje predloženih delova, odluka klijenta (migracija 079)

This commit is contained in:
2026-06-24 19:39:30 +02:00
parent d6b3e7149c
commit 37801fca2a
16 changed files with 599 additions and 180 deletions
+1
View File
@@ -375,6 +375,7 @@ func main() {
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)
r.With(doz("servis.izmeni")).Post("/servis/{id}/predlozeni-delovi/{artikal_id}/obrisi", h.ObrisiPredlozeniDeoNaloga)
r.With(doz("servis.izmeni")).Post("/servis/{id}/radovi", h.DodajRadNalogu)
r.With(doz("servis.izmeni")).Post("/servis/{id}/radovi/{rad_id}/obrisi", h.ObrisiRadNaloga)
r.Get("/izvestaji", h.Izvestaji)
+4
View File
@@ -178,6 +178,7 @@ type KlijentRepository interface {
ListaFilter(ctx context.Context, filter KlijentFilter) ([]model.Klijent, error)
PrebrojiPoFilteru(ctx context.Context, filter KlijentFilter) (int, error)
DohvatiID(ctx context.Context, id int64) (*model.Klijent, error)
Pronadji(ctx context.Context, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mesto string) (*model.Klijent, error)
Kreiraj(ctx context.Context, k *model.Klijent) (int64, error)
Izmeni(ctx context.Context, k *model.Klijent) error
Obrisi(ctx context.Context, id int64) error
@@ -201,6 +202,8 @@ type ServisRepository interface {
AzurirajCenaDijagnostike(ctx context.Context, id int64, cena float64) error
OdbijPopravku(ctx context.Context, id int64, cena float64) error
AzurirajKomentarKlijenta(ctx context.Context, id int64, tekst string) error
SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error
ObrisiOdlukuKlijenta(ctx context.Context, id int64) error
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
SledeciBroj(ctx context.Context) (string, error)
}
@@ -236,6 +239,7 @@ type ServisniPotrazivaniDeloviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error)
Obrisi(ctx context.Context, id int64) error
ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error
ObrisiPredlozeneZaArtikal(ctx context.Context, nalogID, artikalID int64) error
// ProveriIPocistiZaArtikal proverava potraživane redove za dati artikal nakon
// povećanja stanja i briše/smanjuje redove koji se mogu pokriti (FIFO).
// Vraća ID-eve naloga čiji je poslednji potraživani red obrisan.
+110
View File
@@ -3,6 +3,7 @@ package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"ntech/internal/db"
@@ -96,6 +97,115 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent,
return &k, nil
}
// Pronadji traži postojećeg klijenta po imenu i prezimenu (fizičko) ili nazivu firme (pravno).
// Vraća nil, nil ako nije pronađen.
// Pretraga ide od najočiglednijeg ka opštijem: JMBG/PIB → ime+prezime+mesto → telefon → email → samo ime+prezime.
func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mesto string) (*model.Klijent, error) {
var k model.Klijent
var imeN, prezimeN, jmbgN, tipIdent, nazivFirmeN, pibN, telefonN, emailN, mestoN, napomena sql.NullString
skeniraj := func(row *sql.Row) error {
return row.Scan(
&k.ID, &k.Tip, &imeN, &prezimeN, &jmbgN, &tipIdent, &nazivFirmeN, &pibN, &telefonN, &emailN, &mestoN, &napomena, &k.DatumUnosa,
)
}
var row *sql.Row
if tip == "pravno" {
// 1. PIB (najpreciznije za firmu)
if jmbg != "" {
row = r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE tip = 'pravno' AND pib = ? LIMIT 1`, jmbg)
if err := skeniraj(row); err == nil {
goto popuni
}
}
// 2. Naziv firme + mesto
if nazivFirme != "" && mesto != "" {
row = r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? AND mesto = ? LIMIT 1`, nazivFirme, mesto)
if err := skeniraj(row); err == nil {
goto popuni
}
}
// 3. Samo naziv firme
row = r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? LIMIT 1`, nazivFirme)
if err := skeniraj(row); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
}
} else {
// Fizičko lice — više nivoa
// 1. JMBG ili broj lične karte
if jmbg != "" {
row = r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE tip != 'pravno' AND jmbg = ? LIMIT 1`, jmbg)
if err := skeniraj(row); err == nil {
goto popuni
}
}
// 2. Ime + prezime + mesto
if ime != "" && prezime != "" && mesto != "" {
row = r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND mesto = ? LIMIT 1`, ime, prezime, mesto)
if err := skeniraj(row); err == nil {
goto popuni
}
}
// 3. Ime + prezime + telefon
if ime != "" && prezime != "" && telefon != "" {
row = r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND telefon = ? LIMIT 1`, ime, prezime, telefon)
if err := skeniraj(row); err == nil {
goto popuni
}
}
// 4. Ime + prezime + email
if ime != "" && prezime != "" && email != "" {
row = r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND email = ? LIMIT 1`, ime, prezime, email)
if err := skeniraj(row); err == nil {
goto popuni
}
}
// 5. Samo ime + prezime (poslednji fallback)
row = r.db.QueryRowContext(ctx, `
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? LIMIT 1`, ime, prezime)
if err := skeniraj(row); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
}
}
popuni:
k.Ime = imeN.String
k.Prezime = prezimeN.String
k.JMBG = jmbgN.String
k.TipIdentifikacije = tipIdent.String
k.NazivFirme = nazivFirmeN.String
k.PIB = pibN.String
k.Telefon = telefonN.String
k.Email = emailN.String
k.Mesto = mestoN.String
k.Napomena = napomena.String
return &k, nil
}
// Kreiraj dodaje novog klijenta u bazu
func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) {
if k.Tip == "" {
+37 -7
View File
@@ -58,10 +58,11 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
sn.opis_kvara, sn.trazene_nadogradnje, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
sn.avans, sn.napomena, sn.garancija_do, sn.garancija_dana, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum,
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta,
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke,
COALESCE(kp.naziv, '') AS klijent_naziv,
(EXISTS(SELECT 1 FROM servis_radovi sr WHERE sr.nalog_id = sn.id AND sr.predlozeno = 1)
OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1)) AS ima_predlog
OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1)
OR EXISTS(SELECT 1 FROM servisni_potrazivani_delovi spd WHERE spd.nalog_id = sn.id AND spd.predlozeno = 1)) AS ima_predlog
FROM servisni_nalozi sn
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
WHERE 1=1`
@@ -107,7 +108,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
id, 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_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke
FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog
@@ -159,7 +160,7 @@ func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*mode
id, 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_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke
FROM servisni_nalozi WHERE javni_token = ?`, token)
var n model.ServisniNalog
@@ -349,6 +350,30 @@ func (r *ServisRepo) AzurirajKomentarKlijenta(ctx context.Context, id int64, tek
return nil
}
// ObrisiOdlukuKlijenta poništava prethodnu odluku klijenta (npr. kad se doda novi predlog).
func (r *ServisRepo) ObrisiOdlukuKlijenta(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET odluka_klijenta = NULL, komentar_klijenta = NULL, datum_odluke = NULL WHERE id = ?", id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.ObrisiOdlukuKlijenta: %w", err)
}
return nil
}
// SacuvajOdlukuKlijenta beleži odluku klijenta (prihvaceno/odbijeno) i poruku.
// Ako je prihvaćeno, poziva PrihvatiPredlozene za delove i radove.
func (r *ServisRepo) SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET odluka_klijenta = ?, komentar_klijenta = ?, datum_odluke = CURRENT_TIMESTAMP WHERE id = ?",
odluka, odgovor, id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.SacuvajOdlukuKlijenta: %w", err)
}
return nil
}
// Obrisi briše servisni nalog po ID-u
// Obrisi briše servisni nalog i vraća ugrađene delove na stanje u magacinu (u transakciji)
func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error {
@@ -430,9 +455,9 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string, imaPredlog *bool) error {
var klijentID, tehnicarID sql.NullInt64
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, nalazDijagnostike, uradjeno, javniToken, komentarKlijenta sql.NullString
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, nalazDijagnostike, uradjeno, javniToken, komentarKlijenta, odlukaKlijenta sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime
var garancijaDo, datumZavrsetka, predvidjenDatum, datumOdluke sql.NullTime
var garancijaDana sql.NullInt64
var popravkaOdbijena sql.NullInt64
@@ -440,7 +465,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke,
}
if klijentNaziv != nil {
@@ -505,6 +530,11 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
n.PopravkaOdbijena = popravkaOdbijena.Int64 != 0
n.JavniToken = javniToken.String
n.KomentarKlijenta = komentarKlijenta.String
n.OdlukaKlijenta = odlukaKlijenta.String
if datumOdluke.Valid {
v := datumOdluke.Time
n.DatumOdluke = &v
}
return nil
}
+96 -37
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"log/slog"
"ntech/internal/model"
)
@@ -23,7 +24,7 @@ func NoviServisniDeloviRepo(db *sql.DB) *ServisniDeloviRepo {
func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT sd.id, sd.nalog_id, sd.artikal_id, sd.kolicina, sd.cena_komada, sd.datum,
sd.predlozeno, a.naziv
sd.predlozeno, a.naziv, a.sifra
FROM servisni_delovi sd
JOIN artikli a ON a.id = sd.artikal_id
WHERE sd.nalog_id = ?
@@ -38,7 +39,7 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
var d model.ServisniDeoSaArtiklom
err := redovi.Scan(
&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum,
&d.Predlozeno, &d.ArtikalNaziv,
&d.Predlozeno, &d.ArtikalNaziv, &d.ArtikalSifra,
)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: scan: %w", err)
@@ -53,25 +54,52 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
// stanju, a višak beleži u potraživane delove. Stanje se čita i koristi unutar iste
// transakcije pa lager NIKAD ne ide u minus (nema TOCTOU između čitanja i upisa).
// Vraća koliko je ugrađeno i koliko nedostaje.
//
// Predloženi delovi (predlozeno=true) ne skidaju sa lagera — sve ide u potraživane
// do odluke klijenta. Tek PrihvatiPredlozene skida sa lagera.
func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) {
predInt := 0
if predlozeno {
predInt = 1
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: begin tx: %w", err)
}
defer tx.Rollback()
ugradjeno, nedostaje, err = ugradiIliPotrazujTx(ctx, tx, nalogID, artikalID, kolicina, cenaKomada, korisnikID, predlozeno)
if err != nil {
return 0, 0, err
}
if err = tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: commit: %w", err)
}
return ugradjeno, nedostaje, nil
}
// ugradiIliPotrazujTx je interna verzija koja radi unutar postojeće transakcije.
func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) {
// Predloženi delovi: ne skidaju sa lagera, svaki predlog je poseban red (ne merge)
if predlozeno {
slog.Info("PREDLOG_INSERT", "nalogID", nalogID, "artikalID", artikalID, "kolicina", kolicina)
_, err = tx.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)",
nalogID, artikalID, kolicina, cenaKomada,
)
if err != nil {
slog.Error("PREDLOG_INSERT_ERR", "err", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err)
}
slog.Info("PREDLOG_INSERT_OK")
return 0, kolicina, nil
}
// Ugrađeni delovi: standardna logika — skidamo sa lagera
var stanjePre int
if err = tx.QueryRowContext(ctx,
"SELECT kolicina FROM artikli WHERE id = ?", artikalID,
).Scan(&stanjePre); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: dohvati stanje: %w", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: dohvati stanje: %w", err)
}
// ugrađujemo najviše onoliko koliko fizički imamo; ostatak ide u potraživane
ugradjeno = kolicina
if ugradjeno > stanjePre {
ugradjeno = stanjePre
@@ -81,75 +109,69 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art
}
nedostaje = kolicina - ugradjeno
// ugradi ono što imamo: merge u servisni_delovi, skini sa magacina, zabeleži promenu
if ugradjeno > 0 {
var postojeciID int64
var postojeciKol int
errRed := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = ?",
nalogID, artikalID, predInt,
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 0",
nalogID, artikalID,
).Scan(&postojeciID, &postojeciKol)
if errRed == nil {
if _, err = tx.ExecContext(ctx,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
postojeciKol+ugradjeno, cenaKomada, postojeciID,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: merge: %w", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: merge: %w", err)
}
} else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
VALUES (?, ?, ?, ?, ?)`,
nalogID, artikalID, ugradjeno, cenaKomada, predInt,
if _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)",
nalogID, artikalID, ugradjeno, cenaKomada,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: insert: %w", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: insert: %w", err)
}
} else {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: proveri: %w", errRed)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: proveri: %w", errRed)
}
stanjePosle := stanjePre - ugradjeno
if _, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: update stanje: %w", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: update stanje: %w", err)
}
if err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaIzlazServis,
-ugradjeno, stanjePre, stanjePosle, nalogID, korisnikID, ""); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: magacin: %w", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: magacin: %w", err)
}
}
// višak → potraživani delovi (ne skidamo sa lagera dok ne stigne)
if nedostaje > 0 {
var postojeciID int64
var postojeciKol int
errRed := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = ?",
nalogID, artikalID, predInt,
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 0",
nalogID, artikalID,
).Scan(&postojeciID, &postojeciKol)
if errRed == nil {
if _, err = tx.ExecContext(ctx,
"UPDATE servisni_potrazivani_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
postojeciKol+nedostaje, cenaKomada, postojeciID,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani update: %w", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: potraživani update: %w", err)
}
} else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, ?)",
nalogID, artikalID, nedostaje, cenaKomada, predInt,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)",
nalogID, artikalID, nedostaje, cenaKomada,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani insert: %w", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: potraživani insert: %w", err)
}
} else {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani proveri: %w", errRed)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: potraživani proveri: %w", errRed)
}
}
if err = tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: commit: %w", err)
}
return ugradjeno, nedostaje, nil
}
@@ -207,6 +229,7 @@ func (r *ServisniDeloviRepo) Obrisi(ctx context.Context, id int64, korisnikID *i
// PrihvatiPredlozene prebacuje sve predložene delove (i potraživane) naloga u
// ugrađene (predlozeno → 0) — poziva se kad klijent prihvati predlog popravke.
// Sada zaista skida sa lagera: predloženi delovi prethodno nisu dirali lager.
func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
@@ -214,15 +237,51 @@ func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
"UPDATE servisni_delovi SET predlozeno = 0 WHERE nalog_id = ? AND predlozeno = 1", nalogID,
); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: delovi: %w", err)
// Dohvati sve predložene potraživane delove
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.PrihvatiPredlozene: dohvati predlozene: %w", err)
}
defer redovi.Close()
type predlozeniStavka struct {
id int64
artikalID int64
kolicina int
cenaKomada float64
}
var stavke []predlozeniStavka
for redovi.Next() {
var s predlozeniStavka
if err := redovi.Scan(&s.id, &s.artikalID, &s.kolicina, &s.cenaKomada); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: scan: %w", err)
}
stavke = append(stavke, s)
}
// Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može)
for _, s := range stavke {
_, _, err := ugradiIliPotrazujTx(ctx, tx, nalogID, s.artikalID, s.kolicina, s.cenaKomada, nil, false)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: ugradi: %w", err)
}
}
// Obriši predložene potraživane (prebačeni su u ugrađene/potraživane bez predlozeno)
if _, err := tx.ExecContext(ctx,
"UPDATE servisni_potrazivani_delovi SET predlozeno = 0 WHERE nalog_id = ? AND predlozeno = 1", nalogID,
"DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID,
); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: potraživani: %w", err)
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: obrisi potraživane: %w", err)
}
// Očisti i servisni_delovi sa predlozeno=1 (zaostali iz starije verzije)
if _, err := tx.ExecContext(ctx,
"DELETE FROM servisni_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID,
); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: obrisi delove: %w", err)
}
if err := tx.Commit(); err != nil {
@@ -54,6 +54,18 @@ func (r *ServisniPotrazivaniDeloviRepo) Obrisi(ctx context.Context, id int64) er
return nil
}
// ObrisiPredlozeneZaArtikal briše samo predlozene redove (predlozeno=1) za dati artikal na nalogu
func (r *ServisniPotrazivaniDeloviRepo) ObrisiPredlozeneZaArtikal(ctx context.Context, nalogID, artikalID int64) error {
_, err := r.db.ExecContext(ctx,
"DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 1",
nalogID, artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ObrisiPredlozeneZaArtikal: %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,
@@ -96,7 +108,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
}
redovi, err := tx.QueryContext(ctx,
"SELECT id, nalog_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE artikal_id = ? ORDER BY datum",
"SELECT id, nalog_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE artikal_id = ? AND predlozeno = 0 ORDER BY datum",
artikalID,
)
if err != nil {
@@ -165,7 +177,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
for nalogID := range obrisaniNalozi {
var preostalo int
if err := tx.QueryRowContext(ctx,
"SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id = ?", nalogID,
"SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 0", nalogID,
).Scan(&preostalo); err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: prebroji: %w", err)
}
@@ -187,7 +199,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
var postojeciID int64
var postojeciKol int
err := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ?",
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 0",
nalogID, artikalID,
).Scan(&postojeciID, &postojeciKol)
@@ -124,6 +124,50 @@ func TestPotrazivaniDelimicnoPokrivanje(t *testing.T) {
}
}
// TestPotrazivaniPredlozeniSeNePokrivaju: predloženi delovi (predlozeno=1) ne smeju
// biti auto-konvertovani u ugrađene niti smeti otključavanje naloga.
func TestPotrazivaniPredlozeniSeNePokrivaju(t *testing.T) {
db := testDB(t)
ctx := context.Background()
if _, err := db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'GPU', 5)"); err != nil {
t.Fatalf("insert artikal: %v", err)
}
if _, err := db.ExecContext(ctx,
"INSERT INTO servisni_nalozi (id, broj_naloga, uredjaj, opis_kvara, status) VALUES (1, 'SN-1', 'PC', 'kvar', 'U dijagnostici')",
); err != nil {
t.Fatalf("insert nalog: %v", err)
}
// predloženi deo serviser predlaže klijentu (predlozeno=1) — ne sme biti auto-ugradjen
if _, err := db.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (1, 1, 3, 100, 1)",
); err != nil {
t.Fatalf("insert predlozeni: %v", err)
}
potrRepo := NoviServisniPotrazivaniDeloviRepo(db)
otkljucani, err := potrRepo.ProveriIPocistiZaArtikal(ctx, 1)
if err != nil {
t.Fatalf("ProveriIPocistiZaArtikal: %v", err)
}
// nalog se NE otključava — predlozeni redovi se ignorišu
if len(otkljucani) != 0 {
t.Fatalf("nalog ne sme da se otključa zbog predlozenih redova, dobijeno %v", otkljucani)
}
// predlozeni red mora ostati nepromenjen
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_potrazivani_delovi WHERE nalog_id=1 AND predlozeno=1"); k != 3 {
t.Fatalf("predlozeni deo mora ostati 3, dobijeno %d", k)
}
// magacin se ne sme dirljati
if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 5 {
t.Fatalf("stanje magacina mora ostati 5, dobijeno %d", stanje)
}
// u servisni_delovi ne sme biti ništa ubačeno
if k := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_delovi WHERE nalog_id=1"); k != 0 {
t.Fatalf("servisni_delovi mora biti prazan, dobijeno %d", k)
}
}
// skalarInt vraća jednu celobrojnu vrednost iz upita (za proveru stanja u testu)
func skalarInt(t *testing.T, db *sql.DB, upit string) int {
t.Helper()
+6 -3
View File
@@ -21,8 +21,11 @@ func NoviServisniRadoviRepo(baza *sql.DB) *ServisniRadoviRepo {
// DohvatiZaNalog vraća sve radove (usluge) jednog naloga, redom unosa
func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT id, nalog_id, COALESCE(usluga_id, 0), naziv, kolicina, cena_komada, datum, predlozeno
FROM servis_radovi WHERE nalog_id = ? ORDER BY id`, nalogID)
SELECT sr.id, sr.nalog_id, COALESCE(sr.usluga_id, 0), sr.naziv, sr.kolicina, sr.cena_komada, sr.datum, sr.predlozeno,
COALESCE(u.sifra, '')
FROM servis_radovi sr
LEFT JOIN usluge u ON u.id = sr.usluga_id
WHERE sr.nalog_id = ? ORDER BY sr.id`, nalogID)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err)
}
@@ -31,7 +34,7 @@ func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
var radovi []model.ServisniRad
for redovi.Next() {
var rad model.ServisniRad
if err := redovi.Scan(&rad.ID, &rad.NalogID, &rad.UslugaID, &rad.Naziv, &rad.Kolicina, &rad.CenaKomada, &rad.Datum, &rad.Predlozeno); err != nil {
if err := redovi.Scan(&rad.ID, &rad.NalogID, &rad.UslugaID, &rad.Naziv, &rad.Kolicina, &rad.CenaKomada, &rad.Datum, &rad.Predlozeno, &rad.Sifra); err != nil {
return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err)
}
radovi = append(radovi, rad)
+148 -61
View File
@@ -535,8 +535,11 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
continue
}
for _, nalogID := range otkljucani {
if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil {
slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err)
// reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd.
if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove {
if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil {
slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err)
}
}
}
}
@@ -747,11 +750,13 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
imeArtikla = art.Naziv
}
// forma „Predloženi delovi" šalje predlozeno=1; forma „Ugrađeni delovi" ne šalje
predlozeno := r.FormValue("predlozeno") == "1"
// predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje)
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID)
predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno
slog.Info("DODAJ_DEO_IN", "status", nalog.Status, "predlozeno", predlozeno, "kol", kolicina)
// atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane
ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID, predlozeno)
slog.Info("DODAJ_DEO_OUT", "ugradjeno", ugradjeno, "nedostaje", nedostaje, "err", err)
if err != nil {
slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
@@ -759,8 +764,9 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return
}
// ako nešto nedostaje, prebaci nalog u „Čeka delove" i obavesti korisnika
if nedostaje > 0 {
// ako nešto nedostaje kod ugrađenog dela, prebaci nalog u „Čeka delove".
// Za predložene delove ne menjamo status — to su samo predlozi, ne blokiraju rad.
if nedostaje > 0 && !predlozeno {
if e := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusCekaDelove); e != nil {
slog.Error("greška pri prebacivanju naloga u Čeka delove", "error", e)
}
@@ -775,6 +781,16 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
}
}
if nedostaje > 0 && predlozeno {
middleware.SetFlash(w, r, h.DB, "uspeh",
"Predlog sačuvan — "+strconv.Itoa(kolicina)+" kom. „"+imeArtikla+
"\" čeka odobrenje klijenta.")
// novi predlog poništava prethodnu odluku klijenta
if err := h.ServisRepo.ObrisiOdlukuKlijenta(r.Context(), nalogID); err != nil {
slog.Error("greška pri brisanju odluke klijenta", "error", err)
}
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
}
@@ -795,26 +811,43 @@ func (h *Handler) deloviSaPotrazivanima(ctx context.Context, nalogID int64) ([]m
artikalID int64
predlozeno bool
}
potrMap := make(map[kljucDela]int)
type potrVrednost struct {
kolicina int
cena float64
}
potrMap := make(map[kljucDela]potrVrednost)
for _, p := range potrazivani {
potrMap[kljucDela{p.ArtikalID, p.Predlozeno}] += p.Kolicina
k := kljucDela{p.ArtikalID, p.Predlozeno}
v := potrMap[k]
v.kolicina += p.Kolicina
if v.cena == 0 && p.CenaKomada > 0 {
v.cena = p.CenaKomada
}
potrMap[k] = v
}
for i := range delovi {
k := kljucDela{delovi[i].ArtikalID, delovi[i].Predlozeno}
if kol, ok := potrMap[k]; ok {
delovi[i].Potrazivano = kol
if v, ok := potrMap[k]; ok {
delovi[i].Potrazivano = v.kolicina
delete(potrMap, k)
}
}
for k, kol := range potrMap {
for k, v := range potrMap {
naziv := ""
sifra := ""
if art, e := h.Artikli.DohvatiID(ctx, k.artikalID); e == nil && art != nil {
naziv = art.Naziv
sifra = art.Sifra
}
delovi = append(delovi, model.ServisniDeoSaArtiklom{
ArtikalNaziv: naziv,
ServisniDeo: model.ServisniDeo{ArtikalID: k.artikalID, Predlozeno: k.predlozeno},
Potrazivano: kol,
ArtikalSifra: sifra,
ServisniDeo: model.ServisniDeo{
ArtikalID: k.artikalID,
Kolicina: v.kolicina,
CenaKomada: v.cena,
Predlozeno: k.predlozeno,
},
})
}
return delovi, nil
@@ -853,6 +886,28 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
}
// ObrisiPredlozeniDeoNaloga briše predlozene redove (predlozeno=1) za dati artikal na nalogu
func (h *Handler) ObrisiPredlozeniDeoNaloga(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
return
}
nalogID, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
artikalID, err := parseID(chi.URLParam(r, "artikal_id"))
if err != nil {
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
return
}
if err := h.ServisniPotrazivaniDeloviRepo.ObrisiPredlozeneZaArtikal(r.Context(), nalogID, artikalID); err != nil {
slog.Error("greška pri brisanju predloženog dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju predloženog dela.")
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
}
// DodajRadNalogu dodaje stavku rada (uslugu) na nalog; naziv i cena se snapshot-uju
func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
@@ -898,8 +953,9 @@ func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) {
return
}
// forma „Predložene usluge" šalje predlozeno=1; forma „Radovi" ne šalje
predlozeno := r.FormValue("predlozeno") == "1"
// predlog ako status nije „Primljeno" (server odlučuje, ne forma)
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID)
predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno
if _, err := h.ServisniRadoviRepo.Dodaj(r.Context(), nalogID, uslugaID, usluga.Naziv, kolicina, cena, predlozeno); err != nil {
slog.Error("greška pri dodavanju rada", "error", err)
@@ -908,6 +964,13 @@ func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) {
return
}
// novi predlog poništava prethodnu odluku klijenta
if predlozeno {
if err := h.ServisRepo.ObrisiOdlukuKlijenta(r.Context(), nalogID); err != nil {
slog.Error("greška pri brisanju odluke klijenta", "error", err)
}
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
}
@@ -1052,10 +1115,26 @@ func (h *Handler) mozdaKreirajKlijenta(ctx context.Context, r *http.Request, nal
tipIdent = "jmbg" // prazan unos — podrazumevani tip radi konzistentnosti baze
}
prezime := strings.TrimSpace(r.FormValue("prezime"))
telefon := strings.TrimSpace(r.FormValue("telefon"))
mestoLok := strings.TrimSpace(r.FormValue("mesto"))
// prvo proveri da li klijent već postoji u bazi
postojeci, err := h.KlijentiRepo.Pronadji(ctx, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mestoLok)
if err != nil {
slog.Error("greška pri pretrazi klijenta", "error", err)
return "Došlo je do greške pri proveri klijenta. Pokušajte ponovo."
}
if postojeci != nil {
nalog.KlijentID = &postojeci.ID
return ""
}
// klijent ne postoji — kreiraj novog
klijent := model.Klijent{
Tip: tip,
Ime: ime,
Prezime: strings.TrimSpace(r.FormValue("prezime")),
Prezime: prezime,
JMBG: jmbg,
TipIdentifikacije: tipIdent,
NazivFirme: nazivFirme,
@@ -2008,24 +2087,24 @@ func (h *Handler) OdbijPopravku(w http.ResponseWriter, r *http.Request) {
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
type PodaciJavnogStatusa struct {
Nalog model.ServisniNalog
NazivFirme string
LogoPutanja string
Podnazlov string
Adresa string
Telefon string
PIB string
MaticniBroj string
Radovi []model.ServisniRad // ugrađeni/odobreni
ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/odobreni
UkupnoSve float64 // zbir ugrađenih (radovi + delovi)
PredlozeniRadovi []model.ServisniRad
PredlozeniDelovi []model.ServisniDeoSaArtiklom
UkupnoPredlog float64 // zbir predloženih (radovi + delovi)
ImaPredlog bool
UkupnoSaPredlogom float64 // ukupno ugrađeno + predloženo
Odgovoreno bool // klijent već odgovorio (nema predloga, nije „Primljeno")
SviStatusi []string
Nalog model.ServisniNalog
NazivFirme string
LogoPutanja string
Podnazlov string
Adresa string
Telefon string
PIB string
MaticniBroj string
Radovi []model.ServisniRad // ugrađeni/odobreni
ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/odobreni
UkupnoSve float64 // zbir ugrađenih (radovi + delovi)
PredlozeniRadovi []model.ServisniRad
PredlozeniDelovi []model.ServisniDeoSaArtiklom
UkupnoPredlog float64 // zbir predloženih (radovi + delovi)
ImaPredlog bool
UkupnoSaPredlogom float64 // ukupno ugrađeno + predloženo
Odgovoreno bool // klijent već odgovorio (nema predloga, nije „Primljeno")
SviStatusi []string
}
// ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda
@@ -2077,24 +2156,24 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
Nalog: *nalog,
NazivFirme: podesavanja["naziv_firme"],
LogoPutanja: logoZaDokument(podesavanja),
Podnazlov: podesavanja["podnazlov"],
Adresa: podesavanja["adresa"],
Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"],
MaticniBroj: podesavanja["maticni_broj"],
Radovi: ugrRadovi,
ServisniDelovi: ugrDelovi,
UkupnoSve: ukupnoSve,
PredlozeniRadovi: predRadovi,
PredlozeniDelovi: predDelovi,
UkupnoPredlog: ukupnoPredlog,
Nalog: *nalog,
NazivFirme: podesavanja["naziv_firme"],
LogoPutanja: logoZaDokument(podesavanja),
Podnazlov: podesavanja["podnazlov"],
Adresa: podesavanja["adresa"],
Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"],
MaticniBroj: podesavanja["maticni_broj"],
Radovi: ugrRadovi,
ServisniDelovi: ugrDelovi,
UkupnoSve: ukupnoSve,
PredlozeniRadovi: predRadovi,
PredlozeniDelovi: predDelovi,
UkupnoPredlog: ukupnoPredlog,
ImaPredlog: len(predRadovi) > 0 || len(predDelovi) > 0,
UkupnoSaPredlogom: ukupnoSve + ukupnoPredlog,
Odgovoreno: !(len(predRadovi) > 0 || len(predDelovi) > 0) && nalog.Status != model.StatusPrimljeno,
SviStatusi: model.SviStatusi,
Odgovoreno: nalog.OdlukaKlijenta != "" || (!(len(predRadovi) > 0 || len(predDelovi) > 0) && nalog.Status != model.StatusPrimljeno),
SviStatusi: model.SviStatusi,
})
}
@@ -2136,6 +2215,12 @@ func (h *Handler) ServisJavniPrihvati(w http.ResponseWriter, r *http.Request) {
return
}
// odluka je već doneta — ne dozvoljavamo ponovno glasanje
if nalog.OdlukaKlijenta != "" {
http.Redirect(w, r, "/status/"+token, http.StatusSeeOther)
return
}
if err := h.ServisniRadoviRepo.PrihvatiPredlozene(r.Context(), nalog.ID); err != nil {
slog.Error("greška pri prihvatanju predloženih radova", "error", err, "nalog_id", nalog.ID)
http.Error(w, "Greška pri prihvatanju predloga.", http.StatusInternalServerError)
@@ -2147,11 +2232,9 @@ func (h *Handler) ServisJavniPrihvati(w http.ResponseWriter, r *http.Request) {
return
}
// sačuvaj komentar klijenta
if komentar := strings.TrimSpace(r.FormValue("komentar")); komentar != "" {
if err := h.ServisRepo.AzurirajKomentarKlijenta(r.Context(), nalog.ID, komentar); err != nil {
slog.Error("greška pri čuvanju komentara klijenta", "error", err, "nalog_id", nalog.ID)
}
komentar := strings.TrimSpace(r.FormValue("komentar"))
if err := h.ServisRepo.SacuvajOdlukuKlijenta(r.Context(), nalog.ID, "prihvaceno", 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)
@@ -2199,6 +2282,12 @@ func (h *Handler) ServisJavniOdbij(w http.ResponseWriter, r *http.Request) {
return
}
// odluka je već doneta — ne dozvoljavamo ponovno glasanje
if nalog.OdlukaKlijenta != "" {
http.Redirect(w, r, "/status/"+token, http.StatusSeeOther)
return
}
// brišemo predložene stavke — klijent ih je odbio
if err := h.ServisniRadoviRepo.ObrisiPredlozene(r.Context(), nalog.ID); err != nil {
slog.Error("greška pri brisanju predloženih radova", "error", err, "nalog_id", nalog.ID)
@@ -2207,11 +2296,9 @@ func (h *Handler) ServisJavniOdbij(w http.ResponseWriter, r *http.Request) {
slog.Error("greška pri brisanju predloženih delova", "error", err, "nalog_id", nalog.ID)
}
// sačuvaj komentar klijenta
if komentar := strings.TrimSpace(r.FormValue("komentar")); komentar != "" {
if err := h.ServisRepo.AzurirajKomentarKlijenta(r.Context(), nalog.ID, komentar); err != nil {
slog.Error("greška pri čuvanju komentara klijenta", "error", err, "nalog_id", nalog.ID)
}
komentar := strings.TrimSpace(r.FormValue("komentar"))
if err := h.ServisRepo.SacuvajOdlukuKlijenta(r.Context(), nalog.ID, "odbijeno", 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)
+9 -5
View File
@@ -50,11 +50,13 @@ type ServisniNalog struct {
PinUredjaja string
Pribor string
NapomenaKlijentu string
NalazDijagnostike string // šta je serviser utvrdio pregledom (dijagnoza + predlog popravke)
Uradjeno string // šta je serviser stvarno uradio tokom popravke
CenaDijagnostike float64 // taksa kad klijent ne prihvati popravku; 0 = ne naplaćuje se
PopravkaOdbijena bool // klijent odbio popravku posle dijagnostike → naplaćuje se samo dijagnostika
KomentarKlijenta string // poruka klijenta uz prihvatanje/odbijanje predloga
NalazDijagnostike string // šta je serviser utvrdio pregledom (dijagnoza + predlog popravke)
Uradjeno string // šta je serviser stvarno uradio tokom popravke
CenaDijagnostike float64 // taksa kad klijent ne prihvati popravku; 0 = ne naplaćuje se
PopravkaOdbijena bool // klijent odbio popravku posle dijagnostike → naplaćuje se samo dijagnostika
KomentarKlijenta string // poruka klijenta uz prihvatanje/odbijanje predloga
OdlukaKlijenta string // '' / 'prihvaceno' / 'odbijeno'
DatumOdluke *time.Time // kada je klijent doneo odluku
JavniToken string
}
@@ -78,6 +80,7 @@ func (d ServisniDeo) Ukupno() float64 {
type ServisniDeoSaArtiklom struct {
ServisniDeo
ArtikalNaziv string
ArtikalSifra string
Potrazivano int // komada koji nedostaju (iz servisni_potrazivani_delovi), 0 ako nema
}
@@ -102,6 +105,7 @@ type ServisniRad struct {
NalogID int64
UslugaID int64 // 0 ako je usluga u međuvremenu obrisana iz cenovnika
Naziv string
Sifra string
Kolicina float64
CenaKomada float64
Datum string
@@ -0,0 +1,3 @@
-- odluka klijenta o predloženim delovima/uslugama (prihvatanje/odbijanje)
ALTER TABLE servisni_nalozi ADD COLUMN odluka_klijenta TEXT DEFAULT '';
ALTER TABLE servisni_nalozi ADD COLUMN datum_odluke DATETIME;
+80 -36
View File
@@ -9,6 +9,8 @@
.detalji-kartica:nth-child(3) { animation-delay: 0.20s; }
.detalji-kartica:nth-child(4) { animation-delay: 0.28s; }
.detalji-kartica:nth-child(5) { animation-delay: 0.36s; }
/* bez animacija na detaljima — smetaju pri dodavanju/brisanju stavki */
#sadrzaj .kartica.animiraj { animation: none; }
</style>
{{end}}
@@ -158,8 +160,8 @@
Nalepnica (reprint)
</a>
{{end}}
{{/* nalog gotov/preuzet — nema više izmena */}}
{{if and (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}}
{{/* nalog u popravci ili gotov/preuzet — nema više izmena */}}
{{if and (ne .Nalog.Status "U popravci") (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}}
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
Izmeni nalog
</a>
@@ -441,6 +443,7 @@
</div>
{{end}}
<div id="stavke-blok" style="display:flex;flex-direction:column;gap:16px;">
{{/* kartice radova/delova se ne prikazuju kad je popravka odbijena (samo dijagnostika)
ni kad je nalog „Završeno"/„Preuzeto" (posao gotov — sume ostaju u kartici Ukupno) */}}
{{if and (not .Nalog.PopravkaOdbijena) (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}}
@@ -452,7 +455,7 @@
</div>
<!-- forma za dodavanje rada — u dijagnostici se automatski beleži kao predlog -->
{{if index .Dozvole "servis.izmeni"}}
{{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "U popravci")}}
<form method="POST" action="/servis/{{.Nalog.ID}}/radovi" style="margin-bottom:16px;" data-refresh="#radovi-blok, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if .ZakljucaniUgradjeni}}<input type="hidden" name="predlozeno" value="1">{{end}}
@@ -487,6 +490,7 @@
<thead>
<tr style="border-bottom:0.5px solid var(--ivica);">
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Usluga</th>
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:90px;">Šifra</th>
<th style="padding:8px 10px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:80px;">Kol.</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Cena/kom</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Ukupno</th>
@@ -497,6 +501,7 @@
{{range .ServisniRadovi}}
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</td>
<td style="padding:9px 10px;font-size:12px;color:var(--tekst-sporedni);font-family:monospace;">{{.Sifra}}</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>
@@ -517,11 +522,12 @@
{{range .PredlozeniRadovi}}
<tr style="border-bottom:0.5px solid var(--ivica);background:rgba(245,158,11,0.08);">
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.Naziv}} <span class="predlog-bedz" style="font-size:10px;padding:1px 7px;">Predlog</span></td>
<td style="padding:9px 10px;font-size:12px;color:var(--tekst-sporedni);font-family:monospace;">{{.Sifra}}</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;">
{{if index $.Dozvole "servis.izmeni"}}
{{if and (index $.Dozvole "servis.izmeni") (ne $.Nalog.Status "U popravci")}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/radovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#radovi-blok, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit"
@@ -548,13 +554,18 @@
<!-- ugrađeni delovi -->
<div id="delovi-blok">
{{/* osvežava window._ntechArtikli i dropdown svaki put kad data-refresh osvežava #delovi-blok */}}
<script>
window._ntechArtikli = {{json .Artikli}};
if (typeof window._ntechPopuniArtikle === 'function') window._ntechPopuniArtikle();
</script>
<div class="kartica detalji-kartica animiraj" id="delovi-kartica">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);flex-wrap:wrap;gap:10px;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Delovi</span>
</div>
<!-- forma za dodavanje dela — u dijagnostici se automatski beleži kao predlog -->
{{if index .Dozvole "servis.izmeni"}}
{{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "U popravci")}}
<form method="POST" action="/servis/{{.Nalog.ID}}/delovi" style="margin-bottom:16px;" data-refresh="#delovi-blok, #nalog-zaglavlje, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if .ZakljucaniUgradjeni}}<input type="hidden" name="predlozeno" value="1">{{end}}
@@ -596,6 +607,7 @@
<thead>
<tr style="border-bottom:0.5px solid var(--ivica);">
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Artikal</th>
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:90px;">Šifra</th>
<th style="padding:8px 10px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:80px;">Kol.</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Cena/kom</th>
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Ukupno</th>
@@ -606,6 +618,7 @@
{{range .ServisniDelovi}}
<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;font-size:12px;color:var(--tekst-sporedni);font-family:monospace;">{{.ArtikalSifra}}</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>
@@ -630,10 +643,11 @@
</tr>
{{end}}
{{range .PredlozeniDelovi}}
<tr data-artikal-id="{{.ArtikalID}}" data-kolicina="{{.Kolicina}}" data-potrazivano="{{.Potrazivano}}" style="border-bottom:0.5px solid var(--ivica);background:rgba(245,158,11,0.08);{{if .Potrazivano}}background:rgba(220,38,38,0.06);{{end}}">
<tr data-artikal-id="{{.ArtikalID}}" data-kolicina="{{.Kolicina}}" data-potrazivano="{{.Potrazivano}}" style="border-bottom:0.5px solid var(--ivica);background:rgba(245,158,11,0.08);">
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}} <span class="predlog-bedz" style="font-size:10px;padding:1px 7px;">Predlog</span></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 style="padding:9px 10px;font-size:12px;color:var(--tekst-sporedni);font-family:monospace;">{{.ArtikalSifra}}</td>
<td style="padding:9px 10px;text-align:center;font-size:14px;color:var(--tekst-glavni);">
{{if .Kolicina}}{{.Kolicina}}{{if .Potrazivano}} + {{.Potrazivano}}{{end}}{{else}}{{.Potrazivano}}{{end}}
</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">
{{if .Kolicina}}{{dinari .CenaKomada}} din{{else}}—{{end}}
@@ -642,15 +656,22 @@
{{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}}
</td>
<td style="padding:9px 10px;text-align:center;">
{{if and .ID (index $.Dozvole "servis.izmeni")}}
{{if and (index $.Dozvole "servis.izmeni") (ne $.Nalog.Status "U popravci")}}
{{if .ID}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#delovi-blok, #nalog-zaglavlje, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit"
data-potvrda="Ukloniti predloženi deo '{{.ArtikalNaziv}}'?"
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">
×
</button>
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">×</button>
</form>
{{else if .ArtikalID}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/predlozeni-delovi/{{.ArtikalID}}/obrisi" style="display:inline;" data-refresh="#delovi-blok, #nalog-zaglavlje, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit"
data-potvrda="Ukloniti predloženi deo '{{.ArtikalNaziv}}'?"
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">×</button>
</form>
{{end}}
{{end}}
</td>
</tr>
@@ -665,8 +686,32 @@
{{end}}
</div>
</div>
<script>(function(){
var k=document.getElementById('deo-kategorija');
var a=document.getElementById('deo-artikal');
var u=document.getElementById('deo-umagacinu');
var l=document.getElementById('deo-kol');
var c=document.getElementById('deo-cena');
var st=0;
function osv(){if(!l||!a)return;var o=a.options[a.selectedIndex];var s=o?parseInt(o.getAttribute('data-stanje'),10):0;st=isNaN(s)?0:s;if(u)u.textContent=a.value?st:'—';if(l)l.classList.toggle('prekoraceno',a.value&&st===0)}
function pop(){
if(!a)return;
var ka=k?k.value:'';
a.innerHTML='<option value="">— odaberi artikal —</option>';
var aa=window._ntechArtikli||[];
aa.forEach(function(x){if(!ka||(x.KategorijaNaziv||'')===ka)a.innerHTML+='<option value="'+x.ID+'" data-cena="'+x.ProdajnaCena+'" data-stanje="'+(x.Kolicina||0)+'">'+x.Naziv+'</option>'})
}
if(k){var aa2=window._ntechArtikli||[];var kgs=[];aa2.forEach(function(x){var n=x.KategorijaNaziv||'';if(n&&kgs.indexOf(n)===-1)kgs.push(n)})
k.innerHTML='<option value="">— sve kategorije —</option>'+kgs.map(function(x){return'<option value="'+x+'">'+x+'</option>'}).join('');k.onchange=pop}
pop();
if(a)a.onchange=function(){var o=a.options[a.selectedIndex];if(!o)return;var v=parseFloat(o.getAttribute('data-cena'));if(c)c.value=(!isNaN(v)&&v>0)?v.toFixed(2):'0';osv()}
if(l)l.oninput=osv;
var fd=document.querySelector('form[action$=\"/delovi\"]');
if(fd)fd.addEventListener('reset',function(){if(u){u.textContent='—';u.classList.remove('prekoraceno')}st=0;if(l)l.classList.remove('prekoraceno')})
})();</script>
</div>
{{end}}
</div>{{/* kraj stavke-blok */}}
@@ -739,7 +784,8 @@ document.addEventListener('change', function (e) {
// cenu i prikaz „U magacinu". Običan <select> ima native pretragu kucanjem
// i otvara se jednim klikom (za razliku od datalist-a).
(function () {
var ARTIKLI = {{json .Artikli}} || [];
window._ntechArtikli = {{json .Artikli}} || [];
var ARTIKLI = window._ntechArtikli;
var kategSel = document.getElementById('deo-kategorija');
var artikalSel = document.getElementById('deo-artikal');
var cena = document.getElementById('deo-cena');
@@ -748,21 +794,13 @@ document.addEventListener('change', function (e) {
var stanjeIzabrano = 0;
if (!artikalSel) return;
// 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
// boji „U magacinu" i polje količine crveno kada nova količina prelazi
// trenutno stanje; stanjeIzabrano već odražava snižen lager (UgradiIliPotrazuj
// skida sa stanja odmah pri dodavanju), pa nema potrebe sabirati ono što je
// već u tabeli
function osveziPrekoracenje() {
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;
var preko = novaKol > stanjeIzabrano && artikalSel.value;
if (umagacinu) umagacinu.classList.toggle('prekoraceno', preko);
if (kol) kol.classList.toggle('prekoraceno', preko);
}
@@ -779,6 +817,7 @@ document.addEventListener('change', function (e) {
}
function popuniArtikle() {
ARTIKLI = window._ntechArtikli || [];
var izabrana = kategSel ? kategSel.value : '';
artikalSel.innerHTML = '<option value="">— odaberi artikal —</option>' +
ARTIKLI.filter(function (a) { return !izabrana || (a.KategorijaNaziv || '') === izabrana; })
@@ -788,6 +827,7 @@ document.addEventListener('change', function (e) {
}).join('');
if (umagacinu) umagacinu.textContent = '—';
}
window._ntechPopuniArtikle = popuniArtikle;
popuniArtikle();
if (kategSel) kategSel.addEventListener('change', popuniArtikle);
@@ -804,17 +844,21 @@ document.addEventListener('change', function (e) {
// resetuj prikaz „U magacinu" nakon dodavanja dela — base.html poziva
// f.reset() posle AJAX osvežavanja tabele, ali .reset() ne dira <div>
var formaDelovi = document.querySelector('form[action$="/delovi"]');
if (formaDelovi) {
formaDelovi.addEventListener('reset', function () {
if (umagacinu) {
umagacinu.textContent = '—';
umagacinu.classList.remove('prekoraceno');
}
stanjeIzabrano = 0;
if (kol) kol.classList.remove('prekoraceno');
});
function zakaciResetDelovi() {
var fd = document.querySelector('form[action$="/delovi"]');
if (fd) {
fd.addEventListener('reset', function () {
if (umagacinu) {
umagacinu.textContent = '—';
umagacinu.classList.remove('prekoraceno');
}
stanjeIzabrano = 0;
if (kol) kol.classList.remove('prekoraceno');
});
}
}
zakaciResetDelovi();
})();
// Popup polja (predviđen datum, garancija, serviser): nema dugmeta „Sačuvaj" —
+14 -14
View File
@@ -95,6 +95,20 @@
placeholder="npr. punjač, torbica, miš"
style="width:100%;">
</div>
<!-- serviser -->
<div>
<label class="polje-labela">Serviser</label>
<select name="tehnicar_id" style="width:100%;">
<option value="">— nedodeljen —</option>
{{range .Tehnicari}}
<option value="{{.ID}}"
{{if eq .ID $.Nalog.TehnicarIDVrednost}}selected{{end}}>
{{.KorisnickoIme}}
</option>
{{end}}
</select>
</div>
</div>
</div>
@@ -237,20 +251,6 @@
</div>
</template>
</div>
<!-- serviser -->
<div>
<label class="polje-labela">Serviser</label>
<select name="tehnicar_id" style="width:100%;">
<option value="">— nedodeljen —</option>
{{range .Tehnicari}}
<option value="{{.ID}}"
{{if eq .ID $.Nalog.TehnicarIDVrednost}}selected{{end}}>
{{.KorisnickoIme}}
</option>
{{end}}
</select>
</div>
</div>
</div>
{{end}}
+7 -2
View File
@@ -53,14 +53,19 @@
.strana { padding: 16px 20px; max-width: 100%; }
.dugme-stampa { display: none !important; }
@page { size: A4; margin: 12mm 14mm; }
.tabela tr { break-inside: avoid; }
.tabela .ukupno-red { break-before: avoid; }
.tabela .ukupno-red td { break-before: avoid; }
.tabela thead { display: table-header-group; }
.zavrsni-blok { break-inside: avoid; }
/* red tabele se ne sme prelomiti preko granice stranice */
.tabela tr { break-inside: avoid; }
/* „Ukupno radovi/delovi" se ne sme odvojiti od stavki iznad — bez preloma pre njega */
.tabela .ukupno-red { break-before: avoid; }
.tabela .ukupno-red td { break-before: avoid; }
/* zaglavlje tabele se ponavlja na svakoj stranici kad tabela pređe na novu */
/* zaglavlje tabele se ponavlja na svakoj stranici */
.tabela thead { display: table-header-group; }
/* od „Ukupno (procena)" do potpisa sve ostaje na jednoj strani — bez preloma */
/* od „Ukupno (procena)" do potpisa sve ostaje na jednoj strani */
.zavrsni-blok { break-inside: avoid; }
}
.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); }
+12 -10
View File
@@ -50,15 +50,21 @@
.upis-veliki { min-height: 110px; }
.upis-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
/* zavrsetak — vreme i potpis */
.zavrsetak { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 28px; padding-top: 16px; border-top: 0.5px solid #ccc; }
.linija { border-top: 1px solid #888; margin-top: 40px; padding-top: 6px; font-size: 11px; color: #555; }
/* potpis */
.zavrsetak { margin-top: 14px; padding-top: 10px; border-top: 0.5px solid #ccc; }
.linija { border-top: 1px solid #888; margin-top: 24px; padding-top: 4px; font-size: 11px; color: #555; }
@media print {
body { font-size: 12px; }
.strana { padding: 16px 20px; max-width: 100%; }
.strana { padding: 12px 16px; max-width: 100%; }
.dugme-stampa { display: none !important; }
@page { size: A4; margin: 12mm 14mm; }
@page { size: A4; margin: 8mm 10mm; }
.odeljak { margin-bottom: 10px; }
.zaglavlje { margin-bottom: 12px; }
.podaci-grid, .podaci-grid-2 { gap: 8px; }
.upis-veliki { min-height: 80px; }
.upis-okvir { min-height: 50px; }
.zavrsetak { margin-top: 10px; padding-top: 8px; break-inside: avoid; }
/* red tabele se ne sme prelomiti preko granice stranice — prelom pada između stavki */
.tabela tr { break-inside: avoid; }
/* zaglavlje tabele se ponavlja na svakoj stranici kad tabela pređe na novu */
@@ -220,12 +226,8 @@
</div>
{{end}}
<!-- vreme i potpis -->
<!-- potpis -->
<div class="zavrsetak">
<div>
<div class="polje-labela">Datum završetka / utrošeno vreme</div>
<div class="linija">&nbsp;</div>
</div>
<div>
<div class="polje-labela">Potpis servisera</div>
<div class="linija">&nbsp;</div>
@@ -251,6 +251,7 @@
<!-- kartica: predlog popravke — serviser predložio posle dijagnostike -->
{{if .ImaPredlog}}
{{if not .Odgovoreno}}
<div class="kartica predlog-kartica">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span class="kartica-naslov" style="margin:0;">Predlog popravke</span>
@@ -290,11 +291,21 @@
</form>
</div>
{{end}}
{{end}}
{{if .Odgovoreno}}
<div class="kartica" style="border-color:#10b981;text-align:center;">
<div class="kartica" style="{{if eq .Nalog.OdlukaKlijenta "prihvaceno"}}border-color:#10b981{{else if eq .Nalog.OdlukaKlijenta "odbijeno"}}border-color:#ef4444{{else}}border-color:#10b981{{end}};text-align:center;">
{{if eq .Nalog.OdlukaKlijenta "prihvaceno"}}
<div style="font-size:16px;font-weight:600;color:#10b981;">✅ Prihvatili ste predlog</div>
{{else if eq .Nalog.OdlukaKlijenta "odbijeno"}}
<div style="font-size:16px;font-weight:600;color:#ef4444;">✕ Niste prihvatili predlog</div>
{{else}}
<div style="font-size:16px;font-weight:600;color:#10b981;">Hvala na odgovoru!</div>
<div style="font-size:12px;color:#94a3b8;margin-top:6px;">Serviser je obavešten o vašoj odluci.</div>
{{end}}
{{if .Nalog.KomentarKlijenta}}
<div style="font-size:13px;color:#cbd5e1;margin-top:8px;background:#0f172a;padding:8px 12px;border-radius:8px;text-align:left;">{{.Nalog.KomentarKlijenta}}</div>
{{end}}
<div style="font-size:12px;color:#94a3b8;margin-top:8px;">Serviser je obavešten o vašoj odluci.</div>
</div>
{{end}}