diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index e34c00b..5f65cc9 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -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) diff --git a/internal/db/repository.go b/internal/db/repository.go index a2631c0..c916089 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -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 diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index 3afd205..e09d408 100644 --- a/internal/db/sqlite/servis.go +++ b/internal/db/sqlite/servis.go @@ -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 } diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index 3885a82..3c722d5 100644 --- a/internal/db/sqlite/servisni_delovi.go +++ b/internal/db/sqlite/servisni_delovi.go @@ -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 diff --git a/internal/db/sqlite/servisni_potrazivani_delovi.go b/internal/db/sqlite/servisni_potrazivani_delovi.go index 8e3d093..6b3ab96 100644 --- a/internal/db/sqlite/servisni_potrazivani_delovi.go +++ b/internal/db/sqlite/servisni_potrazivani_delovi.go @@ -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) } diff --git a/internal/db/sqlite/servisni_potrazivani_delovi_test.go b/internal/db/sqlite/servisni_potrazivani_delovi_test.go index 7add900..35a6f23 100644 --- a/internal/db/sqlite/servisni_potrazivani_delovi_test.go +++ b/internal/db/sqlite/servisni_potrazivani_delovi_test.go @@ -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) } diff --git a/internal/db/sqlite/servisni_radovi.go b/internal/db/sqlite/servisni_radovi.go index 2d37ab1..28a1e82 100644 --- a/internal/db/sqlite/servisni_radovi.go +++ b/internal/db/sqlite/servisni_radovi.go @@ -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 { diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 117d614..31282b9 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -54,10 +54,14 @@ type PodaciDetaljiNaloga struct { KlijentNaziv string TehnicarNaziv string 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 + GarancijaDefault string // podrazumevana garancija (prijem + meseci), format 2006-01-02 + BezGarancije bool // u bazi nalog nema garanciju (GarancijaDo == NULL) + 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), @@ -1947,20 +2008,24 @@ func (h *Handler) OdbijPopravku(w http.ResponseWriter, r *http.Request) { // PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga type PodaciJavnogStatusa struct { - Nalog model.ServisniNalog - NazivFirme string - LogoPutanja string - Podnazlov string - Adresa string - Telefon string - PIB string - MaticniBroj string - Radovi []model.ServisniRad - UkupnoRad float64 - ServisniDelovi []model.ServisniDeoSaArtiklom - UkupnoDelovi float64 - UkupnoSve float64 - SviStatusi []string + Nalog model.ServisniNalog + NazivFirme string + LogoPutanja string + Podnazlov string + Adresa string + Telefon string + PIB string + MaticniBroj string + Radovi []model.ServisniRad // ugrađeni/odobreni + ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/odobreni + UkupnoSve float64 // zbir ugrađenih (radovi + delovi) + PredlozeniRadovi []model.ServisniRad + PredlozeniDelovi []model.ServisniDeoSaArtiklom + UkupnoPredlog float64 // zbir predloženih (radovi + delovi) + ImaPredlog bool + UkupnoSaPredlogom float64 // ukupno ugrađeno + predloženo + Odgovoreno bool // klijent već odgovorio (nema predloga, nije „Primljeno") + SviStatusi []string } // ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda @@ -1985,31 +2050,51 @@ 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) h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{ - Nalog: *nalog, - NazivFirme: podesavanja["naziv_firme"], - LogoPutanja: logoZaDokument(podesavanja), - Podnazlov: podesavanja["podnazlov"], - Adresa: podesavanja["adresa"], - Telefon: podesavanja["telefon"], - PIB: podesavanja["pib"], - MaticniBroj: podesavanja["maticni_broj"], - Radovi: radovi, - UkupnoRad: ukupnoRad, - ServisniDelovi: delovi, - UkupnoDelovi: ukupnoDelovi, - UkupnoSve: ukupnoRad + ukupnoDelovi, - SviStatusi: model.SviStatusi, + Nalog: *nalog, + NazivFirme: podesavanja["naziv_firme"], + LogoPutanja: logoZaDokument(podesavanja), + Podnazlov: podesavanja["podnazlov"], + Adresa: podesavanja["adresa"], + Telefon: podesavanja["telefon"], + PIB: podesavanja["pib"], + MaticniBroj: podesavanja["maticni_broj"], + Radovi: ugrRadovi, + ServisniDelovi: ugrDelovi, + UkupnoSve: ukupnoSve, + PredlozeniRadovi: predRadovi, + PredlozeniDelovi: predDelovi, + UkupnoPredlog: ukupnoPredlog, + ImaPredlog: len(predRadovi) > 0 || len(predDelovi) > 0, + UkupnoSaPredlogom: ukupnoSve + ukupnoPredlog, + Odgovoreno: !(len(predRadovi) > 0 || len(predDelovi) > 0) && nalog.Status != model.StatusPrimljeno, + SviStatusi: model.SviStatusi, }) } @@ -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) +} diff --git a/internal/model/servis.go b/internal/model/servis.go index a550a29..c7df942 100644 --- a/internal/model/servis.go +++ b/internal/model/servis.go @@ -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 diff --git a/migrations/077_servis_predlozeno.sql b/migrations/077_servis_predlozeno.sql new file mode 100644 index 0000000..a9bfc89 --- /dev/null +++ b/migrations/077_servis_predlozeno.sql @@ -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; diff --git a/migrations/078_servis_komentar_klijenta.sql b/migrations/078_servis_komentar_klijenta.sql new file mode 100644 index 0000000..70caea9 --- /dev/null +++ b/migrations/078_servis_komentar_klijenta.sql @@ -0,0 +1,2 @@ +-- komentar klijenta uz odluku o predlogu (prihvatanje/odbijanje) +ALTER TABLE servisni_nalozi ADD COLUMN komentar_klijenta TEXT DEFAULT ''; diff --git a/web/static/css/main.css b/web/static/css/main.css index 64c780c..a1545cb 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -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, diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index 47c5701..21d7f18 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -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 diff --git a/web/templates/stranice/servis.html b/web/templates/stranice/servis.html index 2772561..f35df40 100644 --- a/web/templates/stranice/servis.html +++ b/web/templates/stranice/servis.html @@ -48,12 +48,11 @@
{{range $.Nalozi}} {{if eq .Status $status}} - +
+ {{if .ImaPredlog}}{{end}} {{.BrojNaloga}} {{if .Napomena}} - +
{{.KomentarKlijenta}}
+
+ {{end}}
{{.Uredjaj}}
{{if .KlijentNaziv}} diff --git a/web/templates/stranice/servis_detalji.html b/web/templates/stranice/servis_detalji.html index 8a58763..e1cd856 100644 --- a/web/templates/stranice/servis_detalji.html +++ b/web/templates/stranice/servis_detalji.html @@ -166,6 +166,24 @@ {{end}}
+ + + {{if .Nalog.KomentarKlijenta}} +
+
+ {{if index .Dozvole "servis.izmeni"}} +
+ + +
+ {{end}} + 💬 + Poruka klijenta +
+
{{.Nalog.KomentarKlijenta}}
+
+ {{end}} +
Datum prijema
@@ -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)
- + {{if index .Dozvole "servis.izmeni"}} -
+ + {{if .ZakljucaniUgradjeni}}{{end}}
@@ -461,7 +481,7 @@
- {{if .ServisniRadovi}} + {{if or .ServisniRadovi .PredlozeniRadovi}}
@@ -481,8 +501,8 @@ {{end}} + {{range .PredlozeniRadovi}} + + + + + + + + {{end}}
{{dinari .CenaKomada}} din {{dinari .Ukupno}} din - {{if index $.Dozvole "servis.izmeni"}} - + {{if and (not $.ZakljucaniUgradjeni) (index $.Dozvole "servis.izmeni")}} +
{{.Naziv}} Predlog{{.Kolicina}}{{dinari .CenaKomada}} din{{dinari .Ukupno}} din + {{if index $.Dozvole "servis.izmeni"}} + + + + + {{end}} +
@@ -504,17 +544,20 @@ {{end}}
+
-
+
+
- Ugrađeni delovi + Delovi
- + {{if index .Dozvole "servis.izmeni"}} -
+ + {{if .ZakljucaniUgradjeni}}{{end}}
@@ -547,7 +590,7 @@
- {{if .ServisniDelovi}} + {{if or .ServisniDelovi .PredlozeniDelovi}}
@@ -573,8 +616,8 @@ {{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}} {{end}} + {{range .PredlozeniDelovi}} + + + + + + + + {{end}}
- {{if and .ID (index $.Dozvole "servis.izmeni")}} - + {{if and (not $.ZakljucaniUgradjeni) (index $.Dozvole "servis.izmeni")}} +
{{.ArtikalNaziv}} Predlog + {{.Kolicina}}{{if .Potrazivano}} + {{.Potrazivano}} na čekanju{{end}} + + {{if .Kolicina}}{{dinari .CenaKomada}} din{{else}}—{{end}} + + {{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}} + + {{if and .ID (index $.Dozvole "servis.izmeni")}} + + + + + {{end}} +
{{else}}
- Nema ugrađenih delova za ovaj nalog. + Nema delova za ovaj nalog.
{{end}}
+
{{end}} + +
diff --git a/web/templates/stranice/servis_predracun.html b/web/templates/stranice/servis_predracun.html index 561d351..a6ac956 100644 --- a/web/templates/stranice/servis_predracun.html +++ b/web/templates/stranice/servis_predracun.html @@ -122,8 +122,22 @@
Uređaj i traženi radovi
Uređaj: {{if .Nalog.Uredjaj}}{{.Nalog.Uredjaj}}{{else}}{{end}}{{if .Nalog.SerijskiBroj}} · S/N: {{.Nalog.SerijskiBroj}}{{end}}
Zahtev klijenta: {{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}{{end}}
+ {{if .Nalog.TrazeneNadogradnje}} +
Tražene nadogradnje: {{.Nalog.TrazeneNadogradnje}}
+ {{end}} + {{if .Nalog.NapomenaKlijentu}} +
Napomena klijentu: {{.Nalog.NapomenaKlijentu}}
+ {{end}}
+ + {{if .Nalog.NalazDijagnostike}} +
+
Utvrđen kvar
+
{{.Nalog.NalazDijagnostike}}
+
+ {{end}} + {{if .Radovi}}
diff --git a/web/templates/stranice/servis_radni_nalog.html b/web/templates/stranice/servis_radni_nalog.html index 9523fca..ce0e4a1 100644 --- a/web/templates/stranice/servis_radni_nalog.html +++ b/web/templates/stranice/servis_radni_nalog.html @@ -86,7 +86,14 @@
Klijent
{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}{{end}}
- {{if .KlijentTelefon}}
Tel: {{telefon .KlijentTelefon}}
{{end}} + {{if .Klijent}} +
+ {{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}
{{end}} + {{if .Klijent.Email}}{{.Klijent.Email}}
{{end}} + {{if .Klijent.Mesto}}{{.Klijent.Mesto}}
{{end}} + {{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}} +
+ {{end}}
@@ -151,10 +158,49 @@ {{end}}
+ + {{if .Nalog.NalazDijagnostike}} +
+
Utvrđeni kvar
+
{{.Nalog.NalazDijagnostike}}
+
+ {{end}} + + + {{if .Nalog.NapomenaKlijentu}} +
+
Napomena klijentu
+
{{.Nalog.NapomenaKlijentu}}
+
+ {{end}} + + + {{if .Radovi}} +
+
Usluge (radovi)
+ + + + + + + + + {{range .Radovi}} + + + + + {{end}} + +
UslugaKoličina
{{.Naziv}}{{.Kolicina}}
+
+ {{end}} + {{if .ServisniDelovi}}
-
Delovi na nalogu
+
Delovi za ugradnju
@@ -174,17 +220,6 @@ {{end}} - -
-
Dijagnoza / utvrđen kvar
-
-
- -
-
Urađen rad
-
-
-
diff --git a/web/templates/stranice/servis_revers.html b/web/templates/stranice/servis_revers.html index 98d7ce9..16d55ee 100644 --- a/web/templates/stranice/servis_revers.html +++ b/web/templates/stranice/servis_revers.html @@ -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 @@ {{if .Uslovi}} -
+
Uslovi servisa
{{.Uslovi}}
diff --git a/web/templates/stranice/servis_status_javni.html b/web/templates/stranice/servis_status_javni.html index 1804534..6ae70a4 100644 --- a/web/templates/stranice/servis_status_javni.html +++ b/web/templates/stranice/servis_status_javni.html @@ -6,21 +6,21 @@ Status popravke — {{.Nalog.BrojNaloga}} @@ -134,6 +158,34 @@
+ + {{if or .Nalog.OpisKvara (or .Nalog.TrazeneNadogradnje .Nalog.NapomenaKlijentu)}} +
+
Vaš zahtev
+ + {{if .Nalog.OpisKvara}} +
+
Opis kvara
+
{{.Nalog.OpisKvara}}
+
+ {{end}} + + {{if .Nalog.TrazeneNadogradnje}} +
+
Tražene nadogradnje
+
{{.Nalog.TrazeneNadogradnje}}
+
+ {{end}} + + {{if .Nalog.NapomenaKlijentu}} +
+
Napomena klijentu
+
{{.Nalog.NapomenaKlijentu}}
+
+ {{end}} +
+ {{end}} +
Detalji
@@ -162,12 +214,6 @@
{{end}} - {{if .Nalog.NapomenaKlijentu}} -
-
Napomena
-
{{.Nalog.NapomenaKlijentu}}
-
- {{end}}
@@ -193,6 +239,62 @@ Ukupno (procena){{dinari .UkupnoSve}} din + + {{if .ImaPredlog}} +
+ Sa predlogom + {{dinari .UkupnoSaPredlogom}} din +
+ {{end}} + + {{end}} + + + {{if .ImaPredlog}} +
+
+ Predlog popravke + Predlog +
+
+ Serviser predlaže sledeće radove i delove. Pogledajte i javite nam da li ste saglasni. +
+ + {{range .PredlozeniRadovi}} +
+ {{.Naziv}}{{if gt .Kolicina 1.0}} ×{{.Kolicina}}{{end}} + {{dinari .Ukupno}} din +
+ {{end}} + + {{range .PredlozeniDelovi}} +
+ {{.ArtikalNaziv}}{{if gt .Kolicina 1}} ×{{.Kolicina}}{{end}} + {{dinari .Ukupno}} din +
+ {{end}} + +
+ Predlog ukupno + {{dinari .UkupnoPredlog}} din +
+ + +
+ +
+ + +
+ +
+ {{end}} + + {{if .Odgovoreno}} +
+
Hvala na odgovoru!
+
Serviser je obavešten o vašoj odluci.
{{end}}