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 e3a1f62da2
commit 173c279eb6
19 changed files with 699 additions and 148 deletions
+4 -1
View File
@@ -204,7 +204,6 @@ func main() {
r := chi.NewRouter()
r.Use(ntechmw.BezbednostHeaders())
r.Use(ntechmw.CsrfMiddleware)
r.Use(middleware.Compress(5))
// deljeno zaključavanje baze za vreme zahteva — obnova backupa (VratiBackup)
// čeka da svi zahtevi završe pre zamene konekcije (vidi handler.ZakljucajCitanje)
@@ -240,9 +239,12 @@ func main() {
r.Post("/setup", h.SacuvajSetup)
r.Get("/odjava", h.Odjava)
r.Get("/status/{token}", h.ServisJavniStatus)
r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati)
r.Post("/status/{token}/odbij", h.ServisJavniOdbij)
// zaštićene rute — zahtevaju prijavljenog korisnika
r.Group(func(r chi.Router) {
r.Use(ntechmw.CsrfMiddleware)
r.Use(ntechmw.RequireAuth(db, totpKljuc))
// doz vraća middleware koji na ruteru zahteva datu dozvolu za mutirajuću
@@ -370,6 +372,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}/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}/radovi", h.DodajRadNalogu)
+11 -2
View File
@@ -200,6 +200,7 @@ type ServisRepository interface {
AzurirajUradjeno(ctx context.Context, id int64, tekst string) error
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
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
SledeciBroj(ctx context.Context) (string, error)
}
@@ -221,9 +222,13 @@ type ServisniDeloviRepository interface {
// UgradiIliPotrazuj atomično (jedna transakcija) čita stanje magacina, ugrađuje
// ono što fizički ima (skida sa lagera, lager NE ide u minus), a višak beleži u
// potraživane delove. Vraća koliko je ugrađeno i koliko nedostaje.
UgradiIliPotrazuj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (ugradjeno, nedostaje int, err error)
UgradiIliPotrazuj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error)
DohvatiArtikalID(ctx context.Context, deoID int64) (int64, error)
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
// PrihvatiPredlozene prebacuje predložene delove (i potraživane) naloga u ugrađene
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
}
// ServisniPotrazivaniDeloviRepository definiše operacije nad delovima koji nedostaju
@@ -240,8 +245,12 @@ type ServisniPotrazivaniDeloviRepository interface {
// ServisniRadoviRepository definiše operacije nad radovima (uslugama) na nalogu
type ServisniRadoviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error)
Dodaj(ctx context.Context, nalogID, uslugaID int64, naziv string, kolicina, cenaKomada float64) (int64, error)
Dodaj(ctx context.Context, nalogID, uslugaID int64, naziv string, kolicina, cenaKomada float64, predlozeno bool) (int64, error)
Obrisi(ctx context.Context, id int64) error
// PrihvatiPredlozene prebacuje predložene radove naloga u redovne
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
}
// MagacinskePromeneRepository definiše operacije nad revizijskim tragom magacina
+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 {
+220 -34
View File
@@ -56,8 +56,12 @@ type PodaciDetaljiNaloga struct {
Tehnicari []model.Korisnik
GarancijaDefault string // podrazumevana garancija (prijem + meseci), format 2006-01-02
BezGarancije bool // u bazi nalog nema garanciju (GarancijaDo == NULL)
ServisniDelovi []model.ServisniDeoSaArtiklom
ServisniRadovi []model.ServisniRad
ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/traženi (predlozeno=false)
ServisniRadovi []model.ServisniRad // ugrađeni/traženi (predlozeno=false)
PredlozeniDelovi []model.ServisniDeoSaArtiklom // serviser predložio posle dijagnostike
PredlozeniRadovi []model.ServisniRad // serviser predložio posle dijagnostike
ImaPredlog bool // postoji bar jedna predložena stavka
ZakljucaniUgradjeni bool // ugrađene stavke zaključane (status nije „Primljeno")
Artikli []model.ArtikalSaKategorijom
Usluge []model.Usluga
Sacuvano bool
@@ -209,6 +213,13 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
return
}
// forma novog naloga ne sadrži polje garancije — primeni podrazumevanu
// garanciju iz podešavanja, osim ako je korisnik izričito izabrao „bez garancije"
if nalog.GarancijaDo == nil && r.FormValue("bez_garancije") != "1" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
nalog.GarancijaDo = defaultGarancija(nalog.DatumPrijema, podesavanja)
}
// garancija ne sme da bude pre datuma prijema
if nalog.GarancijaDo != nil && garancijaPrePrijema(*nalog.GarancijaDo, nalog.DatumPrijema) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
@@ -583,6 +594,24 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
slog.Error("greška pri učitavanju radova", "error", err)
}
// razdvajamo na ugrađene/tražene (predlozeno=false) i predložene (predlozeno=true)
var ugradjeniDelovi, predlozeniDelovi []model.ServisniDeoSaArtiklom
for _, d := range delovi {
if d.Predlozeno {
predlozeniDelovi = append(predlozeniDelovi, d)
} else {
ugradjeniDelovi = append(ugradjeniDelovi, d)
}
}
var ugradjeniRadovi, predlozeniRadovi []model.ServisniRad
for _, rad := range radovi {
if rad.Predlozeno {
predlozeniRadovi = append(predlozeniRadovi, rad)
} else {
ugradjeniRadovi = append(ugradjeniRadovi, rad)
}
}
appdb := appdbPkg.ArtikalFilter{}
artikli, err := h.Artikli.Lista(r.Context(), appdb)
if err != nil {
@@ -643,8 +672,12 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
Tehnicari: tehnicari,
GarancijaDefault: garancijaDefault,
BezGarancije: bezGarancije,
ServisniDelovi: delovi,
ServisniRadovi: radovi,
ServisniDelovi: ugradjeniDelovi,
ServisniRadovi: ugradjeniRadovi,
PredlozeniDelovi: predlozeniDelovi,
PredlozeniRadovi: predlozeniRadovi,
ImaPredlog: len(predlozeniDelovi) > 0 || len(predlozeniRadovi) > 0,
ZakljucaniUgradjeni: jePredlogStatus(nalog.Status),
Artikli: artikli,
Usluge: usluge,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
@@ -662,6 +695,13 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
h.renderujTemplate(w, "servis_detalji", podaci)
}
// jePredlogStatus vraća true ako se stavka dodata u datom statusu računa kao
// predlog servisera (sve posle prijema); dok je nalog „Primljeno" stavke su
// deo zahteva klijenta (ugrađene/tražene).
func jePredlogStatus(status string) bool {
return status != "" && status != model.StatusPrimljeno
}
// DodajDeloNalogu prima POST formu i dodaje artikal kao deo servisnog naloga
func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "servis.izmeni")
@@ -707,8 +747,11 @@ 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"
// 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)
ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID, predlozeno)
if err != nil {
slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
@@ -746,24 +789,31 @@ func (h *Handler) deloviSaPotrazivanima(ctx context.Context, nalogID int64) ([]m
if err != nil {
return delovi, nil // ne prekidamo ako potraživani nisu dostupni
}
potrMap := make(map[int64]int)
// ključ uključuje predlozeno: isti artikal može biti i ugrađen i predložen,
// pa se potraživane količine ne smeju mešati između te dve grupe
type kljucDela struct {
artikalID int64
predlozeno bool
}
potrMap := make(map[kljucDela]int)
for _, p := range potrazivani {
potrMap[p.ArtikalID] += p.Kolicina
potrMap[kljucDela{p.ArtikalID, p.Predlozeno}] += p.Kolicina
}
for i := range delovi {
if kol, ok := potrMap[delovi[i].ArtikalID]; ok {
k := kljucDela{delovi[i].ArtikalID, delovi[i].Predlozeno}
if kol, ok := potrMap[k]; ok {
delovi[i].Potrazivano = kol
delete(potrMap, delovi[i].ArtikalID)
delete(potrMap, k)
}
}
for artikalID, kol := range potrMap {
for k, kol := range potrMap {
naziv := ""
if art, e := h.Artikli.DohvatiID(ctx, artikalID); e == nil && art != nil {
if art, e := h.Artikli.DohvatiID(ctx, k.artikalID); e == nil && art != nil {
naziv = art.Naziv
}
delovi = append(delovi, model.ServisniDeoSaArtiklom{
ArtikalNaziv: naziv,
ServisniDeo: model.ServisniDeo{ArtikalID: artikalID},
ServisniDeo: model.ServisniDeo{ArtikalID: k.artikalID, Predlozeno: k.predlozeno},
Potrazivano: kol,
})
}
@@ -848,7 +898,10 @@ func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := h.ServisniRadoviRepo.Dodaj(r.Context(), nalogID, uslugaID, usluga.Naziv, kolicina, cena); err != nil {
// forma „Predložene usluge" šalje predlozeno=1; forma „Radovi" ne šalje
predlozeno := r.FormValue("predlozeno") == "1"
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)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju rada.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
@@ -1135,9 +1188,10 @@ func logoZaDokument(podesavanja map[string]string) string {
type PodaciRadnogNaloga struct {
Nalog model.ServisniNalog
Radovi []model.ServisniRad
ServisniDelovi []model.ServisniDeoSaArtiklom
Klijent *model.Klijent
KlijentNaziv string
KlijentTelefon string
TehnicarNaziv string
NazivFirme string
LogoPutanja string
@@ -1158,6 +1212,12 @@ func (h *Handler) StampaRadnogNaloga(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)
@@ -1170,17 +1230,17 @@ func (h *Handler) StampaRadnogNaloga(w http.ResponseWriter, r *http.Request) {
return
}
var klijent *model.Klijent
klijentNaziv := ""
klijentTelefon := ""
if nalog.KlijentID != nil {
klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
k, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
if err == nil {
if klijent.NazivFirme != "" {
klijentNaziv = klijent.NazivFirme
klijent = k
if k.NazivFirme != "" {
klijentNaziv = k.NazivFirme
} else {
klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime)
klijentNaziv = strings.TrimSpace(k.Ime + " " + k.Prezime)
}
klijentTelefon = klijent.Telefon
}
}
@@ -1194,9 +1254,10 @@ func (h *Handler) StampaRadnogNaloga(w http.ResponseWriter, r *http.Request) {
h.renderujStandalone(w, "servis_radni_nalog", PodaciRadnogNaloga{
Nalog: *nalog,
Radovi: radovi,
ServisniDelovi: delovi,
Klijent: klijent,
KlijentNaziv: klijentNaziv,
KlijentTelefon: klijentTelefon,
TehnicarNaziv: tehnicarNaziv,
NazivFirme: podesavanja["naziv_firme"],
LogoPutanja: logoZaDokument(podesavanja),
@@ -1955,11 +2016,15 @@ type PodaciJavnogStatusa struct {
Telefon string
PIB string
MaticniBroj string
Radovi []model.ServisniRad
UkupnoRad float64
ServisniDelovi []model.ServisniDeoSaArtiklom
UkupnoDelovi float64
UkupnoSve float64
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
}
@@ -1985,12 +2050,28 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), nalog.ID)
delovi, _ := h.deloviSaPotrazivanima(r.Context(), nalog.ID)
var ukupnoRad, ukupnoDelovi float64
// razdvajamo ugrađene/odobrene od predloženih (čekaju odluku klijenta)
var ugrRadovi, predRadovi []model.ServisniRad
var ukupnoSve, ukupnoPredlog float64
for _, rd := range radovi {
ukupnoRad += rd.Ukupno()
if rd.Predlozeno {
predRadovi = append(predRadovi, rd)
ukupnoPredlog += rd.Ukupno()
} else {
ugrRadovi = append(ugrRadovi, rd)
ukupnoSve += rd.Ukupno()
}
}
var ugrDelovi, predDelovi []model.ServisniDeoSaArtiklom
for _, d := range delovi {
ukupnoDelovi += d.Ukupno()
if d.Predlozeno {
predDelovi = append(predDelovi, d)
ukupnoPredlog += d.Ukupno()
} else {
ugrDelovi = append(ugrDelovi, d)
ukupnoSve += d.Ukupno()
}
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
@@ -2004,11 +2085,15 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"],
MaticniBroj: podesavanja["maticni_broj"],
Radovi: radovi,
UkupnoRad: ukupnoRad,
ServisniDelovi: delovi,
UkupnoDelovi: ukupnoDelovi,
UkupnoSve: ukupnoRad + ukupnoDelovi,
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,
})
}
@@ -2030,3 +2115,104 @@ func qrNalogURL(r *http.Request, token, bazniURL string) string {
}
return scheme + "://" + r.Host + "/status/" + token
}
// ServisJavniPrihvati obrađuje klijentovo prihvatanje predloženih stavki.
// Sve predložene stavke (radovi i delovi) prelaze u ugrađene (predlozeno 1→0).
func (h *Handler) ServisJavniPrihvati(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 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)
return
}
if err := h.ServisniDeloviRepo.PrihvatiPredlozene(r.Context(), nalog.ID); err != nil {
slog.Error("greška pri prihvatanju predloženih delova", "error", err, "nalog_id", nalog.ID)
http.Error(w, "Greška pri prihvatanju predloga.", http.StatusInternalServerError)
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)
}
}
http.Redirect(w, r, "/status/"+token, http.StatusSeeOther)
}
// ObrisiKomentarKlijenta briše poruku klijenta sa naloga
func (h *Handler) ObrisiKomentarKlijenta(w http.ResponseWriter, r *http.Request) {
_, ok := h.zahtevajDozvolu(w, r, "servis.izmeni")
if !ok {
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if err := h.ServisRepo.AzurirajKomentarKlijenta(r.Context(), id, ""); err != nil {
slog.Error("greška pri brisanju komentara klijenta", "error", err, "nalog_id", id)
http.Error(w, "Greška pri brisanju komentara.", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+chi.URLParam(r, "id"), http.StatusSeeOther)
}
// ServisJavniOdbij beleži da je klijent video predloge ali ih ne prihvata.
// Ne briše stavke — serviser ih ručno uklanja ako želi.
func (h *Handler) ServisJavniOdbij(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
}
// 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)
}
if err := h.ServisniDeloviRepo.ObrisiPredlozene(r.Context(), nalog.ID); err != nil {
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)
}
}
http.Redirect(w, r, "/status/"+token, http.StatusSeeOther)
}
+5
View File
@@ -54,6 +54,7 @@ type ServisniNalog struct {
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
JavniToken string
}
@@ -65,6 +66,7 @@ type ServisniDeo struct {
Kolicina int
CenaKomada float64
Datum time.Time
Predlozeno bool // true = serviser predložio posle dijagnostike; čeka odobrenje klijenta
}
// Ukupno vraća ukupnu vrednost dela (kolicina × cena)
@@ -88,6 +90,7 @@ type ServisniPotrazivaniDeo struct {
Kolicina int
CenaKomada float64
Datum time.Time
Predlozeno bool
// za prikaz:
ArtikalNaziv string
}
@@ -102,6 +105,7 @@ type ServisniRad struct {
Kolicina float64
CenaKomada float64
Datum string
Predlozeno bool // true = serviser predložio posle dijagnostike; čeka odobrenje klijenta
}
// Ukupno vraća ukupnu vrednost rada (kolicina × cena)
@@ -113,6 +117,7 @@ func (r ServisniRad) Ukupno() float64 {
type ServisniNalogSaKlijentom struct {
ServisniNalog
KlijentNaziv string
ImaPredlog bool // ima predloženih stavki koje čekaju odobrenje klijenta
}
// KlijentIDVrednost vraća vrednost KlijentID pointera, ili 0 ako je nil
+7
View File
@@ -0,0 +1,7 @@
-- predlozeno: razdvaja stavke unete pri prijemu (zahtev klijenta, predlozeno=0)
-- od onih koje serviser predlaže posle dijagnostike (predlozeno=1).
-- Predložene stavke klijent vidi obojeno na status stranici i odobrava ih;
-- prihvatanjem prelaze u ugrađene (predlozeno → 0).
ALTER TABLE servisni_delovi ADD COLUMN predlozeno INTEGER NOT NULL DEFAULT 0;
ALTER TABLE servis_radovi ADD COLUMN predlozeno INTEGER NOT NULL DEFAULT 0;
ALTER TABLE servisni_potrazivani_delovi ADD COLUMN predlozeno INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
-- komentar klijenta uz odluku o predlogu (prihvatanje/odbijanje)
ALTER TABLE servisni_nalozi ADD COLUMN komentar_klijenta TEXT DEFAULT '';
+10
View File
@@ -1308,6 +1308,9 @@ select {
transform: translateY(-1px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
}
.kanban-ceka-odobrenje {
border-left: 3px solid #f59e0b;
}
/* dugme uz SN broj — otvara internu napomenu */
.napomena-omot {
display: inline-flex;
@@ -1365,6 +1368,13 @@ select {
/* forme „Dodaj rad/deo" u detaljima naloga — grid u CSS (ne inline) da ga @media može nadjačati */
.detalji-forma-rad { display: grid; grid-template-columns: 1fr 80px 120px auto; gap: 10px; align-items: flex-end; }
.detalji-forma-deo { display: grid; grid-template-columns: 1fr 70px 80px 120px auto; gap: 10px; align-items: flex-end; }
/* predlog servisera — kartica sa narandžastim akcentom i bedž „Predlog" */
.predlog-kartica { border-left: 3px solid #f59e0b; }
.predlog-bedz {
display: inline-block; padding: 2px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600; background: #f59e0b; color: #fff;
}
@media (max-width: 600px) {
/* na telefonu: dve kolone, dugme preko cele širine */
.detalji-forma-rad,
+1 -1
View File
@@ -235,7 +235,7 @@ document.addEventListener('alpine:init', () => {
const punoIme = ((k.Ime || '') + ' ' + (k.Prezime || '')).trim()
const polja = (tip === 'pravno') ? [k.NazivFirme] : [punoIme, k.Ime, k.Prezime]
return polja.some(p => (p || '').toLowerCase().startsWith(q))
}).slice(0, 8)
}).slice(0, 100)
},
jeTip(t) {
return this.tip === t
+9 -3
View File
@@ -48,12 +48,11 @@
<div class="kanban-kartice">
{{range $.Nalozi}}
{{if eq .Status $status}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{.ID}}" class="kartica kanban-karta">
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{.ID}}" class="kartica kanban-karta{{if .ImaPredlog}} kanban-ceka-odobrenje{{end}}">
<div style="display:flex;align-items:center;gap:6px;">
{{if .ImaPredlog}}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0;" title="Čeka odobrenje klijenta"></span>{{end}}
<span style="font-size:14px;font-weight:500;color:var(--sb-akcent);font-family:monospace;">{{.BrojNaloga}}</span>
{{if .Napomena}}
<!-- dugme uz SN broj otvara internu napomenu (hover na desktopu, tap→focus na mobilnom);
onclick sprečava da klik na dugme otvori sam nalog (kartica je hx-boost link) -->
<span class="napomena-omot">
<button type="button" class="napomena-dugme" aria-label="Interna napomena"
onclick="event.preventDefault();event.stopPropagation();">
@@ -68,6 +67,13 @@
<div class="karta-napomena">{{.Napomena}}</div>
</span>
{{end}}
{{if .KomentarKlijenta}}
<span class="napomena-omot">
<button type="button" class="napomena-dugme" aria-label="Poruka klijenta"
onclick="event.preventDefault();event.stopPropagation();">💬</button>
<div class="karta-napomena" style="border-color:#f59e0b;">{{.KomentarKlijenta}}</div>
</span>
{{end}}
</div>
<div style="font-size:13px;color:var(--tekst-glavni);margin-top:4px;">{{.Uredjaj}}</div>
{{if .KlijentNaziv}}
+89 -17
View File
@@ -166,6 +166,24 @@
{{end}}
</div>
</div>
<!-- poruka klijenta uz odluku o predlogu -->
{{if .Nalog.KomentarKlijenta}}
<div style="margin-bottom:16px;padding:14px 16px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.3);border-radius:10px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
{{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/obrisi-komentar-klijenta" style="margin:0;" data-refresh="#nalog-zaglavlje">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit" data-potvrda="Obrisati poruku klijenta?" style="background:none;border:none;cursor:pointer;color:#94a3b8;font-size:16px;padding:0 2px;line-height:1;">×</button>
</form>
{{end}}
<span style="font-size:14px;">💬</span>
<span style="font-size:12px;font-weight:600;color:#f59e0b;text-transform:uppercase;letter-spacing:0.5px;flex:1;">Poruka klijenta</span>
</div>
<div style="font-size:14px;color:var(--tekst-glavni);line-height:1.5;white-space:pre-wrap;">{{.Nalog.KomentarKlijenta}}</div>
</div>
{{end}}
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(140px, 1fr));gap:16px;">
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Datum prijema</div>
@@ -426,16 +444,18 @@
{{/* 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")}}
<!-- radovi (usluge) -->
<div id="radovi-blok">
<!-- radovi (usluge) — traženi pri prijemu -->
<div class="kartica detalji-kartica animiraj" id="radovi-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);">Radovi (usluge)</span>
</div>
<!-- forma za dodavanje rada iz cenovnika usluga -->
<!-- forma za dodavanje rada — u dijagnostici se automatski beleži kao predlog -->
{{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/radovi" style="margin-bottom:16px;" data-refresh="#radovi-kartica, #nalog-ukupno">
<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}}
<div class="detalji-forma-rad">
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Usluga</label>
@@ -461,7 +481,7 @@
<!-- lista radova -->
<div id="radovi-spisak">
{{if .ServisniRadovi}}
{{if or .ServisniRadovi .PredlozeniRadovi}}
<div class="tabela-skrol">
<table class="tabela">
<thead>
@@ -481,8 +501,8 @@
<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"}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/radovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#radovi-kartica, #nalog-ukupno">
{{if and (not $.ZakljucaniUgradjeni) (index $.Dozvole "servis.izmeni")}}
<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"
data-potvrda="Ukloniti rad '{{.Naziv}}'?"
@@ -494,6 +514,26 @@
</td>
</tr>
{{end}}
{{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;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"}}
<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"
data-potvrda="Ukloniti predloženi rad '{{.Naziv}}'?"
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">
×
</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
@@ -504,17 +544,20 @@
{{end}}
</div>
</div>
<!-- ugrađeni delovi -->
<div class="kartica detalji-kartica animiraj">
<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);">Ugrađeni delovi</span>
</div>
<!-- forma za dodavanje dela -->
<!-- ugrađeni delovi -->
<div id="delovi-blok">
<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"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/delovi" style="margin-bottom:16px;" data-refresh="#delovi-spisak, #nalog-zaglavlje, #nalog-ukupno">
<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}}
<div style="margin-bottom:10px;">
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Kategorija</label>
<select id="deo-kategorija" style="width:100%;"></select>
@@ -547,7 +590,7 @@
<!-- lista delova -->
<div id="delovi-spisak">
{{if .ServisniDelovi}}
{{if or .ServisniDelovi .PredlozeniDelovi}}
<div class="tabela-skrol">
<table class="tabela">
<thead>
@@ -573,8 +616,8 @@
{{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}}
</td>
<td style="padding:9px 10px;text-align:center;">
{{if and .ID (index $.Dozvole "servis.izmeni")}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#delovi-spisak, #nalog-zaglavlje, #nalog-ukupno">
{{if and (not $.ZakljucaniUgradjeni) (index $.Dozvole "servis.izmeni")}}
<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 deo '{{.ArtikalNaziv}}'?"
@@ -586,18 +629,47 @@
</td>
</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}}">
<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>
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">
{{if .Kolicina}}{{dinari .CenaKomada}} din{{else}}—{{end}}
</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}}
</td>
<td style="padding:9px 10px;text-align:center;">
{{if and .ID (index $.Dozvole "servis.izmeni")}}
<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>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div style="font-size:13px;color:var(--tekst-sporedni);text-align:center;padding:16px 0;">
Nema ugrađenih delova za ovaj nalog.
Nema delova za ovaj nalog.
</div>
{{end}}
</div>
</div>
</div>
{{end}}
<!-- ukupno: zbir usluga (radova) i ugrađenih delova -->
<div id="nalog-ukupno" class="kartica detalji-kartica animiraj">
<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
@@ -122,8 +122,22 @@
<div class="odeljak-naslov">Uređaj i traženi radovi</div>
<div style="margin-bottom:6px;"><strong>Uređaj:</strong> {{if .Nalog.Uredjaj}}{{.Nalog.Uredjaj}}{{else}}<span class="prazno"></span>{{end}}{{if .Nalog.SerijskiBroj}} &middot; S/N: {{.Nalog.SerijskiBroj}}{{end}}</div>
<div class="polje-tekst"><strong>Zahtev klijenta:</strong> {{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}<span class="prazno"></span>{{end}}</div>
{{if .Nalog.TrazeneNadogradnje}}
<div class="polje-tekst" style="margin-top:6px;"><strong>Tražene nadogradnje:</strong> {{.Nalog.TrazeneNadogradnje}}</div>
{{end}}
{{if .Nalog.NapomenaKlijentu}}
<div class="polje-tekst" style="margin-top:6px;"><strong>Napomena klijentu:</strong> {{.Nalog.NapomenaKlijentu}}</div>
{{end}}
</div>
<!-- utvrđen kvar (rezultat dijagnostike) -->
{{if .Nalog.NalazDijagnostike}}
<div class="odeljak">
<div class="odeljak-naslov">Utvrđen kvar</div>
<div class="polje-tekst">{{.Nalog.NalazDijagnostike}}</div>
</div>
{{end}}
<!-- radovi -->
{{if .Radovi}}
<div class="odeljak">
+48 -13
View File
@@ -86,7 +86,14 @@
<div class="zg-okvir" style="font-size:12px;line-height:1.6;">
<div class="polje-labela">Klijent</div>
<div class="polje-vrednost">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}<span class="prazno"></span>{{end}}</div>
{{if .KlijentTelefon}}<div style="color:#555;">Tel: {{telefon .KlijentTelefon}}</div>{{end}}
{{if .Klijent}}
<div style="color:#555;">
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
</div>
{{end}}
</div>
<!-- DESNO: barkod na vrhu, ispod broj + status + datumi + serviser -->
<div class="zg-okvir" style="flex:1.3;">
@@ -151,10 +158,49 @@
{{end}}
</div>
<!-- utvrđen kvar (rezultat dijagnostike) -->
{{if .Nalog.NalazDijagnostike}}
<div class="odeljak">
<div class="odeljak-naslov">Utvrđeni kvar</div>
<div class="polje-tekst">{{.Nalog.NalazDijagnostike}}</div>
</div>
{{end}}
<!-- napomena klijentu -->
{{if .Nalog.NapomenaKlijentu}}
<div class="odeljak">
<div class="odeljak-naslov">Napomena klijentu</div>
<div class="polje-tekst">{{.Nalog.NapomenaKlijentu}}</div>
</div>
{{end}}
<!-- usluge koje serviser treba da uradi — bez cena -->
{{if .Radovi}}
<div class="odeljak">
<div class="odeljak-naslov">Usluge (radovi)</div>
<table class="tabela">
<thead>
<tr>
<th>Usluga</th>
<th class="desno" style="width:90px;">Količina</th>
</tr>
</thead>
<tbody>
{{range .Radovi}}
<tr>
<td>{{.Naziv}}</td>
<td class="desno">{{.Kolicina}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<!-- rezervisani/ugrađeni delovi — bez cena -->
{{if .ServisniDelovi}}
<div class="odeljak">
<div class="odeljak-naslov">Delovi na nalogu</div>
<div class="odeljak-naslov">Delovi za ugradnju</div>
<table class="tabela">
<thead>
<tr>
@@ -174,17 +220,6 @@
</div>
{{end}}
<!-- prazna polja za servisera -->
<div class="odeljak">
<div class="upis-labela">Dijagnoza / utvrđen kvar</div>
<div class="upis-okvir upis-veliki"></div>
</div>
<div class="odeljak">
<div class="upis-labela">Urađen rad</div>
<div class="upis-okvir upis-veliki"></div>
</div>
<!-- vreme i potpis -->
<div class="zavrsetak">
<div>
+3 -1
View File
@@ -59,6 +59,8 @@
.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; }
}
.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; }
@@ -178,7 +180,7 @@
<!-- uslovi servisa -->
{{if .Uslovi}}
<div class="odeljak">
<div class="odeljak uslovi-strana">
<div class="odeljak-naslov">Uslovi servisa</div>
<div class="uslovi-blok">{{.Uslovi}}</div>
</div>
+129 -27
View File
@@ -6,21 +6,21 @@
<title>Status popravke — {{.Nalog.BrojNaloga}}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 14px; color: #111; background: #f5f5f7; min-height: 100vh; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 14px; color: #e2e8f0; background: #0f172a; min-height: 100vh; }
.omotac { max-width: 480px; margin: 0 auto; padding: 24px 16px 48px; }
/* zaglavlje firme */
.zaglavlje { text-align: center; margin-bottom: 24px; }
.firma-naziv { font-size: 18px; font-weight: 700; color: #111; }
.firma-info { font-size: 12px; color: #666; margin-top: 4px; line-height: 1.6; }
.firma-naziv { font-size: 18px; font-weight: 700; color: #f1f5f9; }
.firma-info { font-size: 12px; color: #94a3b8; margin-top: 4px; line-height: 1.6; }
/* kartica naloga */
.kartica { background: #fff; border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.kartica-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #999; margin-bottom: 12px; }
.kartica { background: #1e293b; border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.35); }
.kartica-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #94a3b8; margin-bottom: 12px; }
.uredjaj { font-size: 22px; font-weight: 700; color: #111; line-height: 1.2; }
.broj-naloga { font-size: 13px; color: #888; margin-top: 4px; font-family: monospace; }
.uredjaj { font-size: 22px; font-weight: 700; color: #f1f5f9; line-height: 1.2; }
.broj-naloga { font-size: 13px; color: #94a3b8; margin-top: 4px; font-family: monospace; }
/* status bedž */
.status-bedz {
@@ -40,39 +40,63 @@
/* napredak */
.napredak { margin: 16px 0 4px; }
.napredak-labela { font-size: 11px; color: #999; margin-bottom: 8px; }
.napredak-labela { font-size: 11px; color: #94a3b8; margin-bottom: 8px; }
.koraci { display: flex; gap: 4px; }
.korak {
flex: 1;
height: 5px;
border-radius: 3px;
background: #e5e7eb;
background: #334155;
}
.korak.aktivan { background: #10b981; }
.korak.zavrseno { background: #10b981; }
.korak.tekuci { background: #f59e0b; }
/* red podataka */
.red { display: flex; justify-content: space-between; align-items: flex-start; padding: 10px 0; border-bottom: 0.5px solid #f0f0f0; gap: 12px; }
.red { display: flex; justify-content: space-between; align-items: flex-start; padding: 10px 0; border-bottom: 0.5px solid #334155; gap: 12px; }
.red:last-child { border-bottom: none; }
.red-labela { font-size: 12px; color: #888; min-width: 0; overflow-wrap: anywhere; }
.red-vrednost { font-size: 13px; color: #111; font-weight: 500; text-align: right; flex-shrink: 0; white-space: nowrap; }
.red-labela { font-size: 12px; color: #94a3b8; min-width: 0; overflow-wrap: anywhere; }
.red-vrednost { font-size: 13px; color: #f1f5f9; font-weight: 500; text-align: right; flex-shrink: 0; white-space: nowrap; }
/* cena */
.cena-blok { background: #f9fafb; border-radius: 10px; padding: 14px 16px; margin-top: 4px; }
.cena-blok { background: #0f172a; border-radius: 10px; padding: 14px 16px; margin-top: 4px; }
.cena-od-do { display: flex; gap: 8px; align-items: center; }
.cena-od-do .cena-vrednost { font-size: 20px; font-weight: 700; color: #111; }
.cena-od-do .cena-sep { color: #bbb; }
.cena-labela { font-size: 11px; color: #888; margin-top: 2px; }
.cena-od-do .cena-vrednost { font-size: 20px; font-weight: 700; color: #f1f5f9; }
.cena-od-do .cena-sep { color: #475569; }
.cena-labela { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.cena-vrednost { color: #f1f5f9; }
/* kontakt */
.kontakt { background: #fff; border-radius: 16px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.kontakt-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #999; margin-bottom: 12px; }
.kontakt-tel { font-size: 18px; font-weight: 700; color: #1a56db; text-decoration: none; display: block; }
.kontakt-adresa { font-size: 13px; color: #666; margin-top: 6px; line-height: 1.5; }
.kontakt { background: #1e293b; border-radius: 16px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.35); }
.kontakt-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #94a3b8; margin-bottom: 12px; }
.kontakt-tel { font-size: 18px; font-weight: 700; color: #60a5fa; text-decoration: none; display: block; }
.kontakt-adresa { font-size: 13px; color: #94a3b8; margin-top: 6px; line-height: 1.5; }
/* napomena za klijenta */
.napomena-blok { background: #fffbeb; border-radius: 10px; padding: 12px 14px; margin-top: 4px; font-size: 13px; color: #555; line-height: 1.5; }
.napomena-blok { background: #0f172a; border-left: 3px solid #f59e0b; border-radius: 8px; padding: 12px 14px; margin-top: 4px; font-size: 13px; color: #cbd5e1; line-height: 1.5; white-space: pre-wrap; }
/* predlog popravke — istaknuta kartica koju klijent odobrava */
.predlog-kartica { border: 1px solid #f59e0b; box-shadow: 0 0 0 3px rgba(245,158,11,0.12); }
/* dugmad za odobravanje */
.dugme-prihvati {
width: 100%; padding: 12px 16px; border: none; border-radius: 10px;
font-size: 14px; font-weight: 600; cursor: pointer;
background: #10b981; color: #fff;
transition: background 0.2s;
}
.dugme-prihvati:hover { background: #059669; }
.dugme-odbij {
width: 100%; padding: 12px 16px; border: 1px solid #475569; border-radius: 10px;
font-size: 14px; font-weight: 500; cursor: pointer;
background: transparent; color: #94a3b8;
transition: background 0.2s, color 0.2s;
}
.dugme-odbij:hover { background: #334155; color: #e2e8f0; }
.predlog-bedz { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; background: #f59e0b; color: #1f1300; }
.predlog-ukupno .cena-labela { color: #fcd34d; }
.predlog-ukupno .cena-vrednost { color: #fcd34d; }
</style>
</head>
<body>
@@ -134,6 +158,34 @@
</div>
</div>
<!-- kartica: zahtev klijenta (opis kvara, nadogradnje, napomena) -->
{{if or .Nalog.OpisKvara (or .Nalog.TrazeneNadogradnje .Nalog.NapomenaKlijentu)}}
<div class="kartica">
<div class="kartica-naslov">Vaš zahtev</div>
{{if .Nalog.OpisKvara}}
<div style="margin-bottom:12px;">
<div class="red-labela" style="margin-bottom:6px;">Opis kvara</div>
<div class="napomena-blok">{{.Nalog.OpisKvara}}</div>
</div>
{{end}}
{{if .Nalog.TrazeneNadogradnje}}
<div style="margin-bottom:12px;">
<div class="red-labela" style="margin-bottom:6px;">Tražene nadogradnje</div>
<div class="napomena-blok">{{.Nalog.TrazeneNadogradnje}}</div>
</div>
{{end}}
{{if .Nalog.NapomenaKlijentu}}
<div>
<div class="red-labela" style="margin-bottom:6px;">Napomena klijentu</div>
<div class="napomena-blok">{{.Nalog.NapomenaKlijentu}}</div>
</div>
{{end}}
</div>
{{end}}
<!-- kartica: detalji -->
<div class="kartica">
<div class="kartica-naslov">Detalji</div>
@@ -162,12 +214,6 @@
</div>
{{end}}
{{if .Nalog.NapomenaKlijentu}}
<div style="margin-top:12px;">
<div class="red-labela" style="margin-bottom:6px;">Napomena</div>
<div class="napomena-blok">{{.Nalog.NapomenaKlijentu}}</div>
</div>
{{end}}
</div>
<!-- kartica: radovi i delovi sa procenom -->
@@ -193,6 +239,62 @@
<span class="cena-labela" style="margin:0;">Ukupno (procena)</span>
<span class="cena-vrednost">{{dinari .UkupnoSve}} din</span>
</div>
{{if .ImaPredlog}}
<div class="cena-blok" style="margin-top:8px;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;background:#0f172a;border:1px solid #f59e0b;border-radius:8px;">
<span class="cena-labela" style="margin:0;color:#fbbf24;">Sa predlogom</span>
<span class="cena-vrednost" style="font-size:16px;font-weight:700;color:#fbbf24;">{{dinari .UkupnoSaPredlogom}} din</span>
</div>
{{end}}
</div>
{{end}}
<!-- kartica: predlog popravke — serviser predložio posle dijagnostike -->
{{if .ImaPredlog}}
<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>
<span class="predlog-bedz">Predlog</span>
</div>
<div style="font-size:12px;color:#94a3b8;margin-bottom:10px;line-height:1.5;">
Serviser predlaže sledeće radove i delove. Pogledajte i javite nam da li ste saglasni.
</div>
{{range .PredlozeniRadovi}}
<div class="red">
<span class="red-labela">{{.Naziv}}{{if gt .Kolicina 1.0}} &times;{{.Kolicina}}{{end}}</span>
<span class="red-vrednost">{{dinari .Ukupno}} din</span>
</div>
{{end}}
{{range .PredlozeniDelovi}}
<div class="red">
<span class="red-labela">{{.ArtikalNaziv}}{{if gt .Kolicina 1}} &times;{{.Kolicina}}{{end}}</span>
<span class="red-vrednost">{{dinari .Ukupno}} din</span>
</div>
{{end}}
<div class="cena-blok predlog-ukupno" style="margin-top:12px;display:flex;justify-content:space-between;align-items:center;">
<span class="cena-labela" style="margin:0;">Predlog ukupno</span>
<span class="cena-vrednost">{{dinari .UkupnoPredlog}} din</span>
</div>
<!-- komentar i dugmad -->
<form method="POST" style="margin-top:16px;">
<textarea name="komentar" placeholder="Poruka serviseru (opciono)..." rows="2"
style="width:100%;padding:10px 12px;border:0.5px solid #334155;border-radius:8px;background:#0f172a;color:#e2e8f0;font-size:13px;font-family:inherit;resize:vertical;margin-bottom:10px;box-sizing:border-box;"></textarea>
<div style="display:flex;gap:10px;">
<button type="submit" formaction="/status/{{.Nalog.JavniToken}}/prihvati" class="dugme-prihvati" style="flex:1;">Prihvatam predlog</button>
<button type="submit" formaction="/status/{{.Nalog.JavniToken}}/odbij" class="dugme-odbij" style="flex:1;">Ne prihvatam</button>
</div>
</form>
</div>
{{end}}
{{if .Odgovoreno}}
<div class="kartica" style="border-color:#10b981;text-align:center;">
<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>
</div>
{{end}}