Javno odobravanje predloga: Prihvati/Odbij sa komentarom

- Dodati handleri za prihvatanje i odbijanje predloga sa mobilnog
- Prihvatanje: predložene stavke prelaze u ugrađene (predlozeno 1→0)
- Odbijanje: brišu se predložene stavke
- Komentar klijenta uz odluku (textarea na mobilnom, prikaz u detaljima)
- Oznaka na kanbanu za naloge sa predlozima (narandžasta tačka + leva ivica)
- Oznaka na kanbanu za poruku klijenta (💬 dugme, tap za prikaz)
- Spojene kartice Radovi/Predložene usluge i Delovi/Predloženi delovi
- Auto-detekcija predlozeno=1 kad status != Primljeno
- Obrisana Otključaj/Zaključaj dugmad
- Ukupno sa predlogom na javnoj status stranici
- CSRF middleware premešten u zaštićenu grupu
- Migracija 078: komentar_klijenta
- Repo metode: ObrisiPredlozene, AzurirajKomentarKlijenta
- Popravljeno: 405 na odbij, duple rute, razmak između kartica
This commit is contained in:
2026-06-24 02:41:06 +02:00
parent 3c6b0376b8
commit f61b3bd5ca
19 changed files with 699 additions and 148 deletions
+27 -10
View File
@@ -58,8 +58,10 @@ 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,
COALESCE(kp.naziv, '') AS klijent_naziv
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,
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
FROM servisni_nalozi sn
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
WHERE 1=1`
@@ -88,7 +90,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
var rezultat []model.ServisniNalogSaKlijentom
for redovi.Next() {
var n model.ServisniNalogSaKlijentom
err := scanNalog(redovi.Scan, &n.ServisniNalog, &n.KlijentNaziv)
err := scanNalog(redovi.Scan, &n.ServisniNalog, &n.KlijentNaziv, &n.ImaPredlog)
if err != nil {
return nil, fmt.Errorf("ntech: ServisRepo.Lista: scan: %w", err)
}
@@ -105,11 +107,11 @@ 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
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta
FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog
err := scanNalog(red.Scan, &n, nil)
err := scanNalog(red.Scan, &n, nil, nil)
if err != nil {
return nil, fmt.Errorf("ntech: ServisRepo.DohvatiID: %w", err)
}
@@ -157,11 +159,11 @@ 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
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta
FROM servisni_nalozi WHERE javni_token = ?`, token)
var n model.ServisniNalog
if err := scanNalog(red.Scan, &n, nil); err != nil {
if err := scanNalog(red.Scan, &n, nil, nil); err != nil {
return nil, fmt.Errorf("ntech: ServisRepo.DohvatiJavniToken: %w", err)
}
return &n, nil
@@ -336,6 +338,17 @@ func (r *ServisRepo) AzurirajCenaDijagnostike(ctx context.Context, id int64, cen
return nil
}
// AzurirajKomentarKlijenta čuva poruku klijenta uz prihvatanje/odbijanje predloga
func (r *ServisRepo) AzurirajKomentarKlijenta(ctx context.Context, id int64, tekst string) error {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET komentar_klijenta = ? WHERE id = ?", tekst, id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajKomentarKlijenta: %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 {
@@ -415,9 +428,9 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
// scanNalog čita redove iz upita u ServisniNalog struct —
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error {
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 sql.NullString
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, nalazDijagnostike, uradjeno, javniToken, komentarKlijenta sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime
var garancijaDana sql.NullInt64
@@ -427,12 +440,15 @@ 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,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta,
}
if klijentNaziv != nil {
args = append(args, klijentNaziv)
}
if imaPredlog != nil {
args = append(args, imaPredlog)
}
if err := scan(args...); err != nil {
return err
@@ -488,6 +504,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
}
n.PopravkaOdbijena = popravkaOdbijena.Int64 != 0
n.JavniToken = javniToken.String
n.KomentarKlijenta = komentarKlijenta.String
return nil
}
+67 -12
View File
@@ -23,7 +23,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,
a.naziv
sd.predlozeno, a.naziv
FROM servisni_delovi sd
JOIN artikli a ON a.id = sd.artikal_id
WHERE sd.nalog_id = ?
@@ -38,7 +38,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.ArtikalNaziv,
&d.Predlozeno, &d.ArtikalNaziv,
)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: scan: %w", err)
@@ -53,7 +53,11 @@ 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.
func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (ugradjeno, nedostaje int, err error) {
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)
@@ -82,8 +86,8 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art
var postojeciID int64
var postojeciKol int
errRed := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ?",
nalogID, artikalID,
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = ?",
nalogID, artikalID, predInt,
).Scan(&postojeciID, &postojeciKol)
if errRed == nil {
if _, err = tx.ExecContext(ctx,
@@ -94,9 +98,9 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art
}
} else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada)
VALUES (?, ?, ?, ?)`,
nalogID, artikalID, ugradjeno, cenaKomada,
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
VALUES (?, ?, ?, ?, ?)`,
nalogID, artikalID, ugradjeno, cenaKomada, predInt,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: insert: %w", err)
}
@@ -121,8 +125,8 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art
var postojeciID int64
var postojeciKol int
errRed := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ?",
nalogID, artikalID,
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = ?",
nalogID, artikalID, predInt,
).Scan(&postojeciID, &postojeciKol)
if errRed == nil {
if _, err = tx.ExecContext(ctx,
@@ -133,8 +137,8 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art
}
} else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada) VALUES (?, ?, ?, ?)",
nalogID, artikalID, nedostaje, cenaKomada,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, ?)",
nalogID, artikalID, nedostaje, cenaKomada, predInt,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani insert: %w", err)
}
@@ -201,6 +205,57 @@ func (r *ServisniDeloviRepo) Obrisi(ctx context.Context, id int64, korisnikID *i
return nil
}
// PrihvatiPredlozene prebacuje sve predložene delove (i potraživane) naloga u
// ugrađene (predlozeno → 0) — poziva se kad klijent prihvati predlog popravke.
func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: begin tx: %w", err)
}
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)
}
if _, err := tx.ExecContext(ctx,
"UPDATE servisni_potrazivani_delovi SET predlozeno = 0 WHERE nalog_id = ? AND predlozeno = 1", nalogID,
); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: potraživani: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: commit: %w", err)
}
return nil
}
// ObrisiPredlozene briše sve predložene delove (i potraživane) sa naloga
func (r *ServisniDeloviRepo) ObrisiPredlozene(ctx context.Context, nalogID int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.ObrisiPredlozene: begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
"DELETE FROM servisni_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID,
); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.ObrisiPredlozene: delovi: %w", err)
}
if _, err := tx.ExecContext(ctx,
"DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID,
); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.ObrisiPredlozene: potraživani: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.ObrisiPredlozene: 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
@@ -23,7 +23,7 @@ func NoviServisniPotrazivaniDeloviRepo(db *sql.DB) *ServisniPotrazivaniDeloviRep
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.cena_komada, spd.datum,
a.naziv
spd.predlozeno, a.naziv
FROM servisni_potrazivani_delovi spd
JOIN artikli a ON a.id = spd.artikal_id
WHERE spd.nalog_id = ?
@@ -36,7 +36,7 @@ func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalo
var rezultat []model.ServisniPotrazivaniDeo
for redovi.Next() {
var d model.ServisniPotrazivaniDeo
err := redovi.Scan(&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum, &d.ArtikalNaziv)
err := redovi.Scan(&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum, &d.Predlozeno, &d.ArtikalNaziv)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DohvatiZaNalog: scan: %w", err)
}
@@ -26,7 +26,7 @@ func TestPotrazivaniTok(t *testing.T) {
potrRepo := NoviServisniPotrazivaniDeloviRepo(db)
// traži 5, na stanju 2 → ugradi 2, nedostaje 3
ugradjeno, nedostaje, err := deloviRepo.UgradiIliPotrazuj(ctx, 1, 1, 5, 100, nil)
ugradjeno, nedostaje, err := deloviRepo.UgradiIliPotrazuj(ctx, 1, 1, 5, 100, nil, false)
if err != nil {
t.Fatalf("UgradiIliPotrazuj: %v", err)
}
+33 -7
View File
@@ -21,7 +21,7 @@ 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
SELECT id, nalog_id, COALESCE(usluga_id, 0), naziv, kolicina, cena_komada, datum, predlozeno
FROM servis_radovi WHERE nalog_id = ? ORDER BY id`, nalogID)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err)
@@ -31,7 +31,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); err != nil {
if err := redovi.Scan(&rad.ID, &rad.NalogID, &rad.UslugaID, &rad.Naziv, &rad.Kolicina, &rad.CenaKomada, &rad.Datum, &rad.Predlozeno); err != nil {
return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err)
}
radovi = append(radovi, rad)
@@ -39,16 +39,21 @@ func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
return radovi, redovi.Err()
}
// Dodaj upisuje jednu stavku rada na nalog (radovi ne diraju lager)
func (r *ServisniRadoviRepo) Dodaj(ctx context.Context, nalogID, uslugaID int64, naziv string, kolicina, cenaKomada float64) (int64, error) {
// Dodaj upisuje jednu stavku rada na nalog (radovi ne diraju lager).
// predlozeno=true označava uslugu koju serviser predlaže posle dijagnostike.
func (r *ServisniRadoviRepo) Dodaj(ctx context.Context, nalogID, uslugaID int64, naziv string, kolicina, cenaKomada float64, predlozeno bool) (int64, error) {
var uid any
if uslugaID > 0 {
uid = uslugaID
}
predInt := 0
if predlozeno {
predInt = 1
}
rez, err := r.db.ExecContext(ctx, `
INSERT INTO servis_radovi (nalog_id, usluga_id, naziv, kolicina, cena_komada)
VALUES (?, ?, ?, ?, ?)`,
nalogID, uid, naziv, kolicina, cenaKomada,
INSERT INTO servis_radovi (nalog_id, usluga_id, naziv, kolicina, cena_komada, predlozeno)
VALUES (?, ?, ?, ?, ?, ?)`,
nalogID, uid, naziv, kolicina, cenaKomada, predInt,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniRadoviRepo.Dodaj: %w", err)
@@ -60,6 +65,27 @@ func (r *ServisniRadoviRepo) Dodaj(ctx context.Context, nalogID, uslugaID int64,
return id, nil
}
// PrihvatiPredlozene prebacuje sve predložene radove naloga u redovne (predlozeno → 0)
func (r *ServisniRadoviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int64) error {
if _, err := r.db.ExecContext(ctx,
"UPDATE servis_radovi SET predlozeno = 0 WHERE nalog_id = ? AND predlozeno = 1", nalogID,
); err != nil {
return fmt.Errorf("ntech: ServisniRadoviRepo.PrihvatiPredlozene: %w", err)
}
return nil
}
// ObrisiPredlozene briše sve predložene radove sa naloga (klijent odbio predlog)
func (r *ServisniRadoviRepo) ObrisiPredlozene(ctx context.Context, nalogID int64) error {
_, err := r.db.ExecContext(ctx,
"DELETE FROM servis_radovi WHERE nalog_id = ? AND predlozeno = 1", nalogID,
)
if err != nil {
return fmt.Errorf("ntech: ServisniRadoviRepo.ObrisiPredlozene: %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 {