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 := chi.NewRouter()
r.Use(ntechmw.BezbednostHeaders()) r.Use(ntechmw.BezbednostHeaders())
r.Use(ntechmw.CsrfMiddleware)
r.Use(middleware.Compress(5)) r.Use(middleware.Compress(5))
// deljeno zaključavanje baze za vreme zahteva — obnova backupa (VratiBackup) // deljeno zaključavanje baze za vreme zahteva — obnova backupa (VratiBackup)
// čeka da svi zahtevi završe pre zamene konekcije (vidi handler.ZakljucajCitanje) // čeka da svi zahtevi završe pre zamene konekcije (vidi handler.ZakljucajCitanje)
@@ -240,9 +239,12 @@ func main() {
r.Post("/setup", h.SacuvajSetup) r.Post("/setup", h.SacuvajSetup)
r.Get("/odjava", h.Odjava) r.Get("/odjava", h.Odjava)
r.Get("/status/{token}", h.ServisJavniStatus) 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 // zaštićene rute — zahtevaju prijavljenog korisnika
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(ntechmw.CsrfMiddleware)
r.Use(ntechmw.RequireAuth(db, totpKljuc)) r.Use(ntechmw.RequireAuth(db, totpKljuc))
// doz vraća middleware koji na ruteru zahteva datu dozvolu za mutirajuću // 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}/uradjeno", h.SacuvajUradjeno)
r.With(doz("servis.izmeni")).Post("/servis/{id}/cena-dijagnostike", h.AzurirajCenaDijagnostike) 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}/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", h.DodajDeloNalogu)
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga) 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) 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 AzurirajUradjeno(ctx context.Context, id int64, tekst string) error
AzurirajCenaDijagnostike(ctx context.Context, id int64, cena float64) error AzurirajCenaDijagnostike(ctx context.Context, id int64, cena float64) error
OdbijPopravku(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 Obrisi(ctx context.Context, id int64, korisnikID *int64) error
SledeciBroj(ctx context.Context) (string, error) SledeciBroj(ctx context.Context) (string, error)
} }
@@ -221,9 +222,13 @@ type ServisniDeloviRepository interface {
// UgradiIliPotrazuj atomično (jedna transakcija) čita stanje magacina, ugrađuje // 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 // 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. // 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) DohvatiArtikalID(ctx context.Context, deoID int64) (int64, error)
Obrisi(ctx context.Context, id int64, korisnikID *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 // ServisniPotrazivaniDeloviRepository definiše operacije nad delovima koji nedostaju
@@ -240,8 +245,12 @@ type ServisniPotrazivaniDeloviRepository interface {
// ServisniRadoviRepository definiše operacije nad radovima (uslugama) na nalogu // ServisniRadoviRepository definiše operacije nad radovima (uslugama) na nalogu
type ServisniRadoviRepository interface { type ServisniRadoviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) 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 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 // 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.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.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.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.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 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 FROM servisni_nalozi sn
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
WHERE 1=1` WHERE 1=1`
@@ -88,7 +90,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
var rezultat []model.ServisniNalogSaKlijentom var rezultat []model.ServisniNalogSaKlijentom
for redovi.Next() { for redovi.Next() {
var n model.ServisniNalogSaKlijentom 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 { if err != nil {
return nil, fmt.Errorf("ntech: ServisRepo.Lista: scan: %w", err) 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, id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum, 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) FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog var n model.ServisniNalog
err := scanNalog(red.Scan, &n, nil) err := scanNalog(red.Scan, &n, nil, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ServisRepo.DohvatiID: %w", err) 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, id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum, 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) FROM servisni_nalozi WHERE javni_token = ?`, token)
var n model.ServisniNalog 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 nil, fmt.Errorf("ntech: ServisRepo.DohvatiJavniToken: %w", err)
} }
return &n, nil return &n, nil
@@ -336,6 +338,17 @@ func (r *ServisRepo) AzurirajCenaDijagnostike(ctx context.Context, id int64, cen
return nil 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 po ID-u
// Obrisi briše servisni nalog i vraća ugrađene delove na stanje u magacinu (u transakciji) // 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 { 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 — // scanNalog čita redove iz upita u ServisniNalog struct —
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a // 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 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 cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime
var garancijaDana sql.NullInt64 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.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna, &n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum, &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 { if klijentNaziv != nil {
args = append(args, klijentNaziv) args = append(args, klijentNaziv)
} }
if imaPredlog != nil {
args = append(args, imaPredlog)
}
if err := scan(args...); err != nil { if err := scan(args...); err != nil {
return err return err
@@ -488,6 +504,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
} }
n.PopravkaOdbijena = popravkaOdbijena.Int64 != 0 n.PopravkaOdbijena = popravkaOdbijena.Int64 != 0
n.JavniToken = javniToken.String n.JavniToken = javniToken.String
n.KomentarKlijenta = komentarKlijenta.String
return nil 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) { func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, ` redovi, err := r.db.QueryContext(ctx, `
SELECT sd.id, sd.nalog_id, sd.artikal_id, sd.kolicina, sd.cena_komada, sd.datum, 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 FROM servisni_delovi sd
JOIN artikli a ON a.id = sd.artikal_id JOIN artikli a ON a.id = sd.artikal_id
WHERE sd.nalog_id = ? WHERE sd.nalog_id = ?
@@ -38,7 +38,7 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
var d model.ServisniDeoSaArtiklom var d model.ServisniDeoSaArtiklom
err := redovi.Scan( err := redovi.Scan(
&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum, &d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum,
&d.ArtikalNaziv, &d.Predlozeno, &d.ArtikalNaziv,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: scan: %w", err) 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 // 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). // transakcije pa lager NIKAD ne ide u minus (nema TOCTOU između čitanja i upisa).
// Vraća koliko je ugrađeno i koliko nedostaje. // 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) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: begin tx: %w", err) 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 postojeciID int64
var postojeciKol int var postojeciKol int
errRed := tx.QueryRowContext(ctx, errRed := 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 = ?",
nalogID, artikalID, nalogID, artikalID, predInt,
).Scan(&postojeciID, &postojeciKol) ).Scan(&postojeciID, &postojeciKol)
if errRed == nil { if errRed == nil {
if _, err = tx.ExecContext(ctx, 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) { } else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx, ` if _, err = tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada) INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
nalogID, artikalID, ugradjeno, cenaKomada, nalogID, artikalID, ugradjeno, cenaKomada, predInt,
); err != nil { ); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: insert: %w", err) 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 postojeciID int64
var postojeciKol int var postojeciKol int
errRed := tx.QueryRowContext(ctx, errRed := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ?", "SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = ?",
nalogID, artikalID, nalogID, artikalID, predInt,
).Scan(&postojeciID, &postojeciKol) ).Scan(&postojeciID, &postojeciKol)
if errRed == nil { if errRed == nil {
if _, err = tx.ExecContext(ctx, 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) { } else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx, if _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada) VALUES (?, ?, ?, ?)", "INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, ?)",
nalogID, artikalID, nedostaje, cenaKomada, nalogID, artikalID, nedostaje, cenaKomada, predInt,
); err != nil { ); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani insert: %w", err) 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 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) // DohvatiArtikalID vraća artikal_id za dati servisni deo (pre brisanja)
func (r *ServisniDeloviRepo) DohvatiArtikalID(ctx context.Context, deoID int64) (int64, error) { func (r *ServisniDeloviRepo) DohvatiArtikalID(ctx context.Context, deoID int64) (int64, error) {
var artikalID int64 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) { func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error) {
redovi, err := r.db.QueryContext(ctx, ` redovi, err := r.db.QueryContext(ctx, `
SELECT spd.id, spd.nalog_id, spd.artikal_id, spd.kolicina, spd.cena_komada, spd.datum, 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 FROM servisni_potrazivani_delovi spd
JOIN artikli a ON a.id = spd.artikal_id JOIN artikli a ON a.id = spd.artikal_id
WHERE spd.nalog_id = ? WHERE spd.nalog_id = ?
@@ -36,7 +36,7 @@ func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalo
var rezultat []model.ServisniPotrazivaniDeo var rezultat []model.ServisniPotrazivaniDeo
for redovi.Next() { for redovi.Next() {
var d model.ServisniPotrazivaniDeo 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 { if err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DohvatiZaNalog: scan: %w", err) return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DohvatiZaNalog: scan: %w", err)
} }
@@ -26,7 +26,7 @@ func TestPotrazivaniTok(t *testing.T) {
potrRepo := NoviServisniPotrazivaniDeloviRepo(db) potrRepo := NoviServisniPotrazivaniDeloviRepo(db)
// traži 5, na stanju 2 → ugradi 2, nedostaje 3 // 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 { if err != nil {
t.Fatalf("UgradiIliPotrazuj: %v", err) 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 // DohvatiZaNalog vraća sve radove (usluge) jednog naloga, redom unosa
func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) { func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) {
redovi, err := r.db.QueryContext(ctx, ` 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) FROM servis_radovi WHERE nalog_id = ? ORDER BY id`, nalogID)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err) 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 var radovi []model.ServisniRad
for redovi.Next() { for redovi.Next() {
var rad model.ServisniRad 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) return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err)
} }
radovi = append(radovi, rad) radovi = append(radovi, rad)
@@ -39,16 +39,21 @@ func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
return radovi, redovi.Err() return radovi, redovi.Err()
} }
// Dodaj upisuje jednu stavku rada na nalog (radovi ne diraju lager) // 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) { // 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 var uid any
if uslugaID > 0 { if uslugaID > 0 {
uid = uslugaID uid = uslugaID
} }
predInt := 0
if predlozeno {
predInt = 1
}
rez, err := r.db.ExecContext(ctx, ` rez, err := r.db.ExecContext(ctx, `
INSERT INTO servis_radovi (nalog_id, usluga_id, naziv, kolicina, cena_komada) INSERT INTO servis_radovi (nalog_id, usluga_id, naziv, kolicina, cena_komada, predlozeno)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
nalogID, uid, naziv, kolicina, cenaKomada, nalogID, uid, naziv, kolicina, cenaKomada, predInt,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ServisniRadoviRepo.Dodaj: %w", err) 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 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 // Obrisi uklanja jednu stavku rada sa naloga
func (r *ServisniRadoviRepo) Obrisi(ctx context.Context, id int64) error { 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 { if _, err := r.db.ExecContext(ctx, "DELETE FROM servis_radovi WHERE id = ?", id); err != nil {
+240 -54
View File
@@ -54,10 +54,14 @@ type PodaciDetaljiNaloga struct {
KlijentNaziv string KlijentNaziv string
TehnicarNaziv string TehnicarNaziv string
Tehnicari []model.Korisnik Tehnicari []model.Korisnik
GarancijaDefault string // podrazumevana garancija (prijem + meseci), format 2006-01-02 GarancijaDefault string // podrazumevana garancija (prijem + meseci), format 2006-01-02
BezGarancije bool // u bazi nalog nema garanciju (GarancijaDo == NULL) BezGarancije bool // u bazi nalog nema garanciju (GarancijaDo == NULL)
ServisniDelovi []model.ServisniDeoSaArtiklom ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/traženi (predlozeno=false)
ServisniRadovi []model.ServisniRad 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 Artikli []model.ArtikalSaKategorijom
Usluge []model.Usluga Usluge []model.Usluga
Sacuvano bool Sacuvano bool
@@ -209,6 +213,13 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
return 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 // garancija ne sme da bude pre datuma prijema
if nalog.GarancijaDo != nil && garancijaPrePrijema(*nalog.GarancijaDo, nalog.DatumPrijema) { if nalog.GarancijaDo != nil && garancijaPrePrijema(*nalog.GarancijaDo, nalog.DatumPrijema) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) 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) 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{} appdb := appdbPkg.ArtikalFilter{}
artikli, err := h.Artikli.Lista(r.Context(), appdb) artikli, err := h.Artikli.Lista(r.Context(), appdb)
if err != nil { if err != nil {
@@ -643,8 +672,12 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
Tehnicari: tehnicari, Tehnicari: tehnicari,
GarancijaDefault: garancijaDefault, GarancijaDefault: garancijaDefault,
BezGarancije: bezGarancije, BezGarancije: bezGarancije,
ServisniDelovi: delovi, ServisniDelovi: ugradjeniDelovi,
ServisniRadovi: radovi, ServisniRadovi: ugradjeniRadovi,
PredlozeniDelovi: predlozeniDelovi,
PredlozeniRadovi: predlozeniRadovi,
ImaPredlog: len(predlozeniDelovi) > 0 || len(predlozeniRadovi) > 0,
ZakljucaniUgradjeni: jePredlogStatus(nalog.Status),
Artikli: artikli, Artikli: artikli,
Usluge: usluge, Usluge: usluge,
Sacuvano: r.URL.Query().Get("sacuvano") == "1", 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) 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 // DodajDeloNalogu prima POST formu i dodaje artikal kao deo servisnog naloga
func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "servis.izmeni") 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 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 // 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 { if err != nil {
slog.Error("greška pri dodavanju dela", "error", err) slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") 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 { if err != nil {
return delovi, nil // ne prekidamo ako potraživani nisu dostupni 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 { for _, p := range potrazivani {
potrMap[p.ArtikalID] += p.Kolicina potrMap[kljucDela{p.ArtikalID, p.Predlozeno}] += p.Kolicina
} }
for i := range delovi { 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 delovi[i].Potrazivano = kol
delete(potrMap, delovi[i].ArtikalID) delete(potrMap, k)
} }
} }
for artikalID, kol := range potrMap { for k, kol := range potrMap {
naziv := "" 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 naziv = art.Naziv
} }
delovi = append(delovi, model.ServisniDeoSaArtiklom{ delovi = append(delovi, model.ServisniDeoSaArtiklom{
ArtikalNaziv: naziv, ArtikalNaziv: naziv,
ServisniDeo: model.ServisniDeo{ArtikalID: artikalID}, ServisniDeo: model.ServisniDeo{ArtikalID: k.artikalID, Predlozeno: k.predlozeno},
Potrazivano: kol, Potrazivano: kol,
}) })
} }
@@ -848,7 +898,10 @@ func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) {
return 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) slog.Error("greška pri dodavanju rada", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju rada.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju rada.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) 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 { type PodaciRadnogNaloga struct {
Nalog model.ServisniNalog Nalog model.ServisniNalog
Radovi []model.ServisniRad
ServisniDelovi []model.ServisniDeoSaArtiklom ServisniDelovi []model.ServisniDeoSaArtiklom
Klijent *model.Klijent
KlijentNaziv string KlijentNaziv string
KlijentTelefon string
TehnicarNaziv string TehnicarNaziv string
NazivFirme string NazivFirme string
LogoPutanja string LogoPutanja string
@@ -1158,6 +1212,12 @@ func (h *Handler) StampaRadnogNaloga(w http.ResponseWriter, r *http.Request) {
return 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) delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError) 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 return
} }
var klijent *model.Klijent
klijentNaziv := "" klijentNaziv := ""
klijentTelefon := ""
if nalog.KlijentID != nil { 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 err == nil {
if klijent.NazivFirme != "" { klijent = k
klijentNaziv = klijent.NazivFirme if k.NazivFirme != "" {
klijentNaziv = k.NazivFirme
} else { } 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{ h.renderujStandalone(w, "servis_radni_nalog", PodaciRadnogNaloga{
Nalog: *nalog, Nalog: *nalog,
Radovi: radovi,
ServisniDelovi: delovi, ServisniDelovi: delovi,
Klijent: klijent,
KlijentNaziv: klijentNaziv, KlijentNaziv: klijentNaziv,
KlijentTelefon: klijentTelefon,
TehnicarNaziv: tehnicarNaziv, TehnicarNaziv: tehnicarNaziv,
NazivFirme: podesavanja["naziv_firme"], NazivFirme: podesavanja["naziv_firme"],
LogoPutanja: logoZaDokument(podesavanja), LogoPutanja: logoZaDokument(podesavanja),
@@ -1947,20 +2008,24 @@ func (h *Handler) OdbijPopravku(w http.ResponseWriter, r *http.Request) {
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga // PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
type PodaciJavnogStatusa struct { type PodaciJavnogStatusa struct {
Nalog model.ServisniNalog Nalog model.ServisniNalog
NazivFirme string NazivFirme string
LogoPutanja string LogoPutanja string
Podnazlov string Podnazlov string
Adresa string Adresa string
Telefon string Telefon string
PIB string PIB string
MaticniBroj string MaticniBroj string
Radovi []model.ServisniRad Radovi []model.ServisniRad // ugrađeni/odobreni
UkupnoRad float64 ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/odobreni
ServisniDelovi []model.ServisniDeoSaArtiklom UkupnoSve float64 // zbir ugrađenih (radovi + delovi)
UkupnoDelovi float64 PredlozeniRadovi []model.ServisniRad
UkupnoSve float64 PredlozeniDelovi []model.ServisniDeoSaArtiklom
SviStatusi []string 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 // ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda
@@ -1985,31 +2050,51 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), nalog.ID) radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), nalog.ID)
delovi, _ := h.deloviSaPotrazivanima(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 { 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 { 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) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{ h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
Nalog: *nalog, Nalog: *nalog,
NazivFirme: podesavanja["naziv_firme"], NazivFirme: podesavanja["naziv_firme"],
LogoPutanja: logoZaDokument(podesavanja), LogoPutanja: logoZaDokument(podesavanja),
Podnazlov: podesavanja["podnazlov"], Podnazlov: podesavanja["podnazlov"],
Adresa: podesavanja["adresa"], Adresa: podesavanja["adresa"],
Telefon: podesavanja["telefon"], Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"], PIB: podesavanja["pib"],
MaticniBroj: podesavanja["maticni_broj"], MaticniBroj: podesavanja["maticni_broj"],
Radovi: radovi, Radovi: ugrRadovi,
UkupnoRad: ukupnoRad, ServisniDelovi: ugrDelovi,
ServisniDelovi: delovi, UkupnoSve: ukupnoSve,
UkupnoDelovi: ukupnoDelovi, PredlozeniRadovi: predRadovi,
UkupnoSve: ukupnoRad + ukupnoDelovi, PredlozeniDelovi: predDelovi,
SviStatusi: model.SviStatusi, 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 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 Uradjeno string // šta je serviser stvarno uradio tokom popravke
CenaDijagnostike float64 // taksa kad klijent ne prihvati popravku; 0 = ne naplaćuje se 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 PopravkaOdbijena bool // klijent odbio popravku posle dijagnostike → naplaćuje se samo dijagnostika
KomentarKlijenta string // poruka klijenta uz prihvatanje/odbijanje predloga
JavniToken string JavniToken string
} }
@@ -65,6 +66,7 @@ type ServisniDeo struct {
Kolicina int Kolicina int
CenaKomada float64 CenaKomada float64
Datum time.Time Datum time.Time
Predlozeno bool // true = serviser predložio posle dijagnostike; čeka odobrenje klijenta
} }
// Ukupno vraća ukupnu vrednost dela (kolicina × cena) // Ukupno vraća ukupnu vrednost dela (kolicina × cena)
@@ -88,6 +90,7 @@ type ServisniPotrazivaniDeo struct {
Kolicina int Kolicina int
CenaKomada float64 CenaKomada float64
Datum time.Time Datum time.Time
Predlozeno bool
// za prikaz: // za prikaz:
ArtikalNaziv string ArtikalNaziv string
} }
@@ -102,6 +105,7 @@ type ServisniRad struct {
Kolicina float64 Kolicina float64
CenaKomada float64 CenaKomada float64
Datum string Datum string
Predlozeno bool // true = serviser predložio posle dijagnostike; čeka odobrenje klijenta
} }
// Ukupno vraća ukupnu vrednost rada (kolicina × cena) // Ukupno vraća ukupnu vrednost rada (kolicina × cena)
@@ -113,6 +117,7 @@ func (r ServisniRad) Ukupno() float64 {
type ServisniNalogSaKlijentom struct { type ServisniNalogSaKlijentom struct {
ServisniNalog ServisniNalog
KlijentNaziv string KlijentNaziv string
ImaPredlog bool // ima predloženih stavki koje čekaju odobrenje klijenta
} }
// KlijentIDVrednost vraća vrednost KlijentID pointera, ili 0 ako je nil // 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); transform: translateY(-1px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); 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 */ /* dugme uz SN broj — otvara internu napomenu */
.napomena-omot { .napomena-omot {
display: inline-flex; 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 */ /* 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-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; } .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) { @media (max-width: 600px) {
/* na telefonu: dve kolone, dugme preko cele širine */ /* na telefonu: dve kolone, dugme preko cele širine */
.detalji-forma-rad, .detalji-forma-rad,
+1 -1
View File
@@ -235,7 +235,7 @@ document.addEventListener('alpine:init', () => {
const punoIme = ((k.Ime || '') + ' ' + (k.Prezime || '')).trim() const punoIme = ((k.Ime || '') + ' ' + (k.Prezime || '')).trim()
const polja = (tip === 'pravno') ? [k.NazivFirme] : [punoIme, k.Ime, k.Prezime] const polja = (tip === 'pravno') ? [k.NazivFirme] : [punoIme, k.Ime, k.Prezime]
return polja.some(p => (p || '').toLowerCase().startsWith(q)) return polja.some(p => (p || '').toLowerCase().startsWith(q))
}).slice(0, 8) }).slice(0, 100)
}, },
jeTip(t) { jeTip(t) {
return this.tip === t return this.tip === t
+9 -3
View File
@@ -48,12 +48,11 @@
<div class="kanban-kartice"> <div class="kanban-kartice">
{{range $.Nalozi}} {{range $.Nalozi}}
{{if eq .Status $status}} {{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;"> <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> <span style="font-size:14px;font-weight:500;color:var(--sb-akcent);font-family:monospace;">{{.BrojNaloga}}</span>
{{if .Napomena}} {{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"> <span class="napomena-omot">
<button type="button" class="napomena-dugme" aria-label="Interna napomena" <button type="button" class="napomena-dugme" aria-label="Interna napomena"
onclick="event.preventDefault();event.stopPropagation();"> onclick="event.preventDefault();event.stopPropagation();">
@@ -68,6 +67,13 @@
<div class="karta-napomena">{{.Napomena}}</div> <div class="karta-napomena">{{.Napomena}}</div>
</span> </span>
{{end}} {{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>
<div style="font-size:13px;color:var(--tekst-glavni);margin-top:4px;">{{.Uredjaj}}</div> <div style="font-size:13px;color:var(--tekst-glavni);margin-top:4px;">{{.Uredjaj}}</div>
{{if .KlijentNaziv}} {{if .KlijentNaziv}}
+86 -14
View File
@@ -166,6 +166,24 @@
{{end}} {{end}}
</div> </div>
</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 style="display:grid;grid-template-columns:repeat(auto-fit, minmax(140px, 1fr));gap:16px;">
<div> <div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Datum prijema</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) {{/* 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) */}} 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")}} {{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 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;"> <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> <span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Radovi (usluge)</span>
</div> </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"}} {{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}}"> <input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if .ZakljucaniUgradjeni}}<input type="hidden" name="predlozeno" value="1">{{end}}
<div class="detalji-forma-rad"> <div class="detalji-forma-rad">
<div> <div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Usluga</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Usluga</label>
@@ -461,7 +481,7 @@
<!-- lista radova --> <!-- lista radova -->
<div id="radovi-spisak"> <div id="radovi-spisak">
{{if .ServisniRadovi}} {{if or .ServisniRadovi .PredlozeniRadovi}}
<div class="tabela-skrol"> <div class="tabela-skrol">
<table class="tabela"> <table class="tabela">
<thead> <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;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:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
<td style="padding:9px 10px;text-align:center;"> <td style="padding:9px 10px;text-align:center;">
{{if index $.Dozvole "servis.izmeni"}} {{if and (not $.ZakljucaniUgradjeni) (index $.Dozvole "servis.izmeni")}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/radovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#radovi-kartica, #nalog-ukupno"> <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}}"> <input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit" <button type="submit"
data-potvrda="Ukloniti rad '{{.Naziv}}'?" data-potvrda="Ukloniti rad '{{.Naziv}}'?"
@@ -494,6 +514,26 @@
</td> </td>
</tr> </tr>
{{end}} {{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> </tbody>
</table> </table>
</div> </div>
@@ -504,17 +544,20 @@
{{end}} {{end}}
</div> </div>
</div> </div>
</div>
<!-- ugrađeni delovi --> <!-- ugrađeni delovi -->
<div class="kartica detalji-kartica animiraj"> <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;"> <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> <span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Delovi</span>
</div> </div>
<!-- forma za dodavanje dela --> <!-- forma za dodavanje dela — u dijagnostici se automatski beleži kao predlog -->
{{if index .Dozvole "servis.izmeni"}} {{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}}"> <input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if .ZakljucaniUgradjeni}}<input type="hidden" name="predlozeno" value="1">{{end}}
<div style="margin-bottom:10px;"> <div style="margin-bottom:10px;">
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Kategorija</label> <label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Kategorija</label>
<select id="deo-kategorija" style="width:100%;"></select> <select id="deo-kategorija" style="width:100%;"></select>
@@ -547,7 +590,7 @@
<!-- lista delova --> <!-- lista delova -->
<div id="delovi-spisak"> <div id="delovi-spisak">
{{if .ServisniDelovi}} {{if or .ServisniDelovi .PredlozeniDelovi}}
<div class="tabela-skrol"> <div class="tabela-skrol">
<table class="tabela"> <table class="tabela">
<thead> <thead>
@@ -573,8 +616,8 @@
{{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}} {{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}}
</td> </td>
<td style="padding:9px 10px;text-align:center;"> <td style="padding:9px 10px;text-align:center;">
{{if and .ID (index $.Dozvole "servis.izmeni")}} {{if and (not $.ZakljucaniUgradjeni) (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"> <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}}"> <input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit" <button type="submit"
data-potvrda="Ukloniti deo '{{.ArtikalNaziv}}'?" data-potvrda="Ukloniti deo '{{.ArtikalNaziv}}'?"
@@ -586,18 +629,47 @@
</td> </td>
</tr> </tr>
{{end}} {{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> </tbody>
</table> </table>
</div> </div>
{{else}} {{else}}
<div style="font-size:13px;color:var(--tekst-sporedni);text-align:center;padding:16px 0;"> <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> </div>
{{end}} {{end}}
</div> </div>
</div> </div>
</div>
{{end}} {{end}}
<!-- ukupno: zbir usluga (radova) i ugrađenih delova --> <!-- ukupno: zbir usluga (radova) i ugrađenih delova -->
<div id="nalog-ukupno" class="kartica detalji-kartica animiraj"> <div id="nalog-ukupno" class="kartica detalji-kartica animiraj">
<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);"> <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 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 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> <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> </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 --> <!-- radovi -->
{{if .Radovi}} {{if .Radovi}}
<div class="odeljak"> <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="zg-okvir" style="font-size:12px;line-height:1.6;">
<div class="polje-labela">Klijent</div> <div class="polje-labela">Klijent</div>
<div class="polje-vrednost">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}<span class="prazno"></span>{{end}}</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> </div>
<!-- DESNO: barkod na vrhu, ispod broj + status + datumi + serviser --> <!-- DESNO: barkod na vrhu, ispod broj + status + datumi + serviser -->
<div class="zg-okvir" style="flex:1.3;"> <div class="zg-okvir" style="flex:1.3;">
@@ -151,10 +158,49 @@
{{end}} {{end}}
</div> </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 --> <!-- rezervisani/ugrađeni delovi — bez cena -->
{{if .ServisniDelovi}} {{if .ServisniDelovi}}
<div class="odeljak"> <div class="odeljak">
<div class="odeljak-naslov">Delovi na nalogu</div> <div class="odeljak-naslov">Delovi za ugradnju</div>
<table class="tabela"> <table class="tabela">
<thead> <thead>
<tr> <tr>
@@ -174,17 +220,6 @@
</div> </div>
{{end}} {{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 --> <!-- vreme i potpis -->
<div class="zavrsetak"> <div class="zavrsetak">
<div> <div>
+3 -1
View File
@@ -59,6 +59,8 @@
.strana { padding: 16px 20px; max-width: 100%; } .strana { padding: 16px 20px; max-width: 100%; }
.dugme-stampa { display: none !important; } .dugme-stampa { display: none !important; }
@page { size: A4; margin: 12mm 14mm; } @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 { 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; } .dugme-stampa:hover { background: #333; }
@@ -178,7 +180,7 @@
<!-- uslovi servisa --> <!-- uslovi servisa -->
{{if .Uslovi}} {{if .Uslovi}}
<div class="odeljak"> <div class="odeljak uslovi-strana">
<div class="odeljak-naslov">Uslovi servisa</div> <div class="odeljak-naslov">Uslovi servisa</div>
<div class="uslovi-blok">{{.Uslovi}}</div> <div class="uslovi-blok">{{.Uslovi}}</div>
</div> </div>
+129 -27
View File
@@ -6,21 +6,21 @@
<title>Status popravke — {{.Nalog.BrojNaloga}}</title> <title>Status popravke — {{.Nalog.BrojNaloga}}</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { 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; } .omotac { max-width: 480px; margin: 0 auto; padding: 24px 16px 48px; }
/* zaglavlje firme */ /* zaglavlje firme */
.zaglavlje { text-align: center; margin-bottom: 24px; } .zaglavlje { text-align: center; margin-bottom: 24px; }
.firma-naziv { font-size: 18px; font-weight: 700; color: #111; } .firma-naziv { font-size: 18px; font-weight: 700; color: #f1f5f9; }
.firma-info { font-size: 12px; color: #666; margin-top: 4px; line-height: 1.6; } .firma-info { font-size: 12px; color: #94a3b8; margin-top: 4px; line-height: 1.6; }
/* kartica naloga */ /* 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 { 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: #999; margin-bottom: 12px; } .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; } .uredjaj { font-size: 22px; font-weight: 700; color: #f1f5f9; line-height: 1.2; }
.broj-naloga { font-size: 13px; color: #888; margin-top: 4px; font-family: monospace; } .broj-naloga { font-size: 13px; color: #94a3b8; margin-top: 4px; font-family: monospace; }
/* status bedž */ /* status bedž */
.status-bedz { .status-bedz {
@@ -40,39 +40,63 @@
/* napredak */ /* napredak */
.napredak { margin: 16px 0 4px; } .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; } .koraci { display: flex; gap: 4px; }
.korak { .korak {
flex: 1; flex: 1;
height: 5px; height: 5px;
border-radius: 3px; border-radius: 3px;
background: #e5e7eb; background: #334155;
} }
.korak.aktivan { background: #10b981; } .korak.aktivan { background: #10b981; }
.korak.zavrseno { background: #10b981; } .korak.zavrseno { background: #10b981; }
.korak.tekuci { background: #f59e0b; } .korak.tekuci { background: #f59e0b; }
/* red podataka */ /* 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:last-child { border-bottom: none; }
.red-labela { font-size: 12px; color: #888; min-width: 0; overflow-wrap: anywhere; } .red-labela { font-size: 12px; color: #94a3b8; 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-vrednost { font-size: 13px; color: #f1f5f9; font-weight: 500; text-align: right; flex-shrink: 0; white-space: nowrap; }
/* cena */ /* 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 { display: flex; gap: 8px; align-items: center; }
.cena-od-do .cena-vrednost { font-size: 20px; font-weight: 700; color: #111; } .cena-od-do .cena-vrednost { font-size: 20px; font-weight: 700; color: #f1f5f9; }
.cena-od-do .cena-sep { color: #bbb; } .cena-od-do .cena-sep { color: #475569; }
.cena-labela { font-size: 11px; color: #888; margin-top: 2px; } .cena-labela { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.cena-vrednost { color: #f1f5f9; }
/* kontakt */ /* kontakt */
.kontakt { background: #fff; border-radius: 16px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); } .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: #999; margin-bottom: 12px; } .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: #1a56db; text-decoration: none; display: block; } .kontakt-tel { font-size: 18px; font-weight: 700; color: #60a5fa; text-decoration: none; display: block; }
.kontakt-adresa { font-size: 13px; color: #666; margin-top: 6px; line-height: 1.5; } .kontakt-adresa { font-size: 13px; color: #94a3b8; margin-top: 6px; line-height: 1.5; }
/* napomena za klijenta */ /* 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> </style>
</head> </head>
<body> <body>
@@ -134,6 +158,34 @@
</div> </div>
</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 --> <!-- kartica: detalji -->
<div class="kartica"> <div class="kartica">
<div class="kartica-naslov">Detalji</div> <div class="kartica-naslov">Detalji</div>
@@ -162,12 +214,6 @@
</div> </div>
{{end}} {{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> </div>
<!-- kartica: radovi i delovi sa procenom --> <!-- kartica: radovi i delovi sa procenom -->
@@ -193,6 +239,62 @@
<span class="cena-labela" style="margin:0;">Ukupno (procena)</span> <span class="cena-labela" style="margin:0;">Ukupno (procena)</span>
<span class="cena-vrednost">{{dinari .UkupnoSve}} din</span> <span class="cena-vrednost">{{dinari .UkupnoSve}} din</span>
</div> </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> </div>
{{end}} {{end}}