Servis: popup izmena polja u detaljima (predviđen datum, garancija, tehničar)

- Klik na vrednost otvara modal (obrazac kao „Promeni cenu" u Magacinu);
snima se na zatvaranje modala, samo ako je vrednost promenjena (bez dugmeta Sačuvaj)
- Predviđen datum vraćen u bazu kao ručni override (migracija 071); ako nije
postavljen, prikaz računa izvedeni default (prijem + rok iz podešavanja)
- Nove rute/handleri AzurirajPredvidjenDatum i AzurirajTehnicar
- Garancija usklađena između modala i forme „Izmeni nalog" (isti izgled i logika);
dugme „Podrazumevano" (prijem + meseci) na oba mesta
- Checkbox „Bez garancije" čita stanje iz baze; odčekiravanjem se u kalendaru
prikaže datum (garancija iz baze ili podrazumevani)
- Validacija: garancija ne može biti pre datuma prijema (frontend min + backend)
This commit is contained in:
2026-06-23 02:10:43 +02:00
parent 55a0cfd1ea
commit c3d42ec821
8 changed files with 392 additions and 83 deletions
+2
View File
@@ -360,6 +360,8 @@ func main() {
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/predracun", h.StampaPredracuna) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/predracun", h.StampaPredracuna)
r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus) r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus)
r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija", h.AzurirajGaranciju) r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija", h.AzurirajGaranciju)
r.With(doz("servis.izmeni")).Post("/servis/{id}/predvidjen-datum", h.AzurirajPredvidjenDatum)
r.With(doz("servis.izmeni")).Post("/servis/{id}/tehnicar", h.AzurirajTehnicar)
r.With(doz("servis.izmeni")).Post("/servis/{id}/napomena-klijentu", h.SacuvajNapomenuKlijentu) r.With(doz("servis.izmeni")).Post("/servis/{id}/napomena-klijentu", h.SacuvajNapomenuKlijentu)
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)
+2
View File
@@ -192,6 +192,8 @@ type ServisRepository interface {
Izmeni(ctx context.Context, n *model.ServisniNalog) error Izmeni(ctx context.Context, n *model.ServisniNalog) error
AzurirajStatus(ctx context.Context, id int64, status string) error AzurirajStatus(ctx context.Context, id int64, status string) error
AzurirajGaranciju(ctx context.Context, id int64, garancijaDo *time.Time) error AzurirajGaranciju(ctx context.Context, id int64, garancijaDo *time.Time) error
AzurirajPredvidjenDatum(ctx context.Context, id int64, predvidjenDatum *time.Time) error
AzurirajTehnicar(ctx context.Context, id int64, tehnicarID *int64) error
AzurirajNapomenuKlijentu(ctx context.Context, id int64, tekst string) error AzurirajNapomenuKlijentu(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)
+49 -10
View File
@@ -57,7 +57,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
SELECT SELECT
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.datum_prijema, sn.datum_zavrsetka, sn.avans, sn.napomena, sn.garancija_do, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum,
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.javni_token, sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.javni_token,
COALESCE(kp.naziv, '') AS klijent_naziv COALESCE(kp.naziv, '') AS klijent_naziv
FROM servisni_nalozi sn FROM servisni_nalozi sn
@@ -104,7 +104,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
SELECT SELECT
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, datum_prijema, datum_zavrsetka, avans, napomena, garancija_do, datum_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, javni_token ostecenja, pin_uredjaja, pribor, napomena_klijentu, javni_token
FROM servisni_nalozi WHERE id = ?`, id) FROM servisni_nalozi WHERE id = ?`, id)
@@ -127,14 +127,14 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
rezultat, err := r.db.ExecContext(ctx, ` rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO servisni_nalozi INSERT INTO servisni_nalozi
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje, (klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje,
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka, status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token) ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj, nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje, n.Status, nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje, n.Status,
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna), nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena), nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), nullTime(n.PredvidjenDatum),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor), nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.DatumPrijema, token, n.DatumPrijema, token,
) )
@@ -156,7 +156,7 @@ func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*mode
SELECT SELECT
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, datum_prijema, datum_zavrsetka, avans, napomena, garancija_do, datum_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, javni_token ostecenja, pin_uredjaja, pribor, napomena_klijentu, javni_token
FROM servisni_nalozi WHERE javni_token = ?`, token) FROM servisni_nalozi WHERE javni_token = ?`, token)
@@ -173,12 +173,12 @@ func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
UPDATE servisni_nalozi SET UPDATE servisni_nalozi SET
klijent_id = ?, tehnicar_id = ?, uredjaj = ?, serijski_broj = ?, opis_kvara = ?, trazene_nadogradnje = ?, klijent_id = ?, tehnicar_id = ?, uredjaj = ?, serijski_broj = ?, opis_kvara = ?, trazene_nadogradnje = ?,
status = ?, cena_od = ?, cena_do = ?, cena_konacna = ?, status = ?, cena_od = ?, cena_do = ?, cena_konacna = ?,
avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?, avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?, predvidjen_datum = ?,
ostecenja = ?, pin_uredjaja = ?, pribor = ? ostecenja = ?, pin_uredjaja = ?, pribor = ?
WHERE id = ?`, WHERE id = ?`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje, nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje,
n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna), n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), nullTime(n.PredvidjenDatum),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor), nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.ID, n.ID,
) )
@@ -229,6 +229,41 @@ func (r *ServisRepo) AzurirajGaranciju(ctx context.Context, id int64, garancijaD
return nil return nil
} }
// AzurirajPredvidjenDatum postavlja ili briše ručni override predviđenog datuma popravke.
// predvidjenDatum == nil → vraća se na izvedeni default (prijem + rok iz podešavanja).
func (r *ServisRepo) AzurirajPredvidjenDatum(ctx context.Context, id int64, predvidjenDatum *time.Time) error {
if predvidjenDatum != nil {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET predvidjen_datum = ? WHERE id = ?",
predvidjenDatum.Format("2006-01-02"), id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajPredvidjenDatum: %w", err)
}
} else {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET predvidjen_datum = NULL WHERE id = ?", id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajPredvidjenDatum: %w", err)
}
}
return nil
}
// AzurirajTehnicar postavlja ili uklanja dodeljenog tehničara na nalogu.
// tehnicarID == nil → nedodeljen.
func (r *ServisRepo) AzurirajTehnicar(ctx context.Context, id int64, tehnicarID *int64) error {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET tehnicar_id = ? WHERE id = ?",
nullInt64(tehnicarID), id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajTehnicar: %w", err)
}
return nil
}
// AzurirajNapomenuKlijentu postavlja tekst napomene namenjene klijentu na nalogu // AzurirajNapomenuKlijentu postavlja tekst napomene namenjene klijentu na nalogu
func (r *ServisRepo) AzurirajNapomenuKlijentu(ctx context.Context, id int64, tekst string) error { func (r *ServisRepo) AzurirajNapomenuKlijentu(ctx context.Context, id int64, tekst string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
@@ -323,12 +358,12 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
var klijentID, tehnicarID sql.NullInt64 var klijentID, tehnicarID sql.NullInt64
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, javniToken sql.NullString var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, javniToken sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64 var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka sql.NullTime var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime
args := []any{ args := []any{
&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, &n.DatumPrijema, &datumZavrsetka, &avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &javniToken, &ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &javniToken,
} }
@@ -378,6 +413,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := datumZavrsetka.Time v := datumZavrsetka.Time
n.DatumZavrsetka = &v n.DatumZavrsetka = &v
} }
if predvidjenDatum.Valid {
v := predvidjenDatum.Time
n.PredvidjenDatum = &v
}
n.JavniToken = javniToken.String n.JavniToken = javniToken.String
return nil return nil
+159 -13
View File
@@ -38,6 +38,8 @@ type PodaciFormeNaloga struct {
SviStatusi []string SviStatusi []string
Greska string Greska string
Izmena bool Izmena bool
GarancijaDefault string // podrazumevana garancija (prijem + meseci), format 2006-01-02
BezGarancije bool // u bazi nalog nema garanciju (GarancijaDo == NULL)
} }
// PodaciDetaljiNaloga su podaci za pregled jednog servisnog naloga // PodaciDetaljiNaloga su podaci za pregled jednog servisnog naloga
@@ -46,6 +48,9 @@ type PodaciDetaljiNaloga struct {
Nalog model.ServisniNalog Nalog model.ServisniNalog
KlijentNaziv string KlijentNaziv string
TehnicarNaziv 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 ServisniDelovi []model.ServisniDeoSaArtiklom
ServisniRadovi []model.ServisniRad ServisniRadovi []model.ServisniRad
Artikli []model.ArtikalSaKategorijom Artikli []model.ArtikalSaKategorijom
@@ -196,6 +201,26 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
return return
} }
// garancija ne sme da bude pre datuma prijema
if nalog.GarancijaDo != nil && garancijaPrePrijema(*nalog.GarancijaDo, nalog.DatumPrijema) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Novi nalog"
h.renderujFormuNaloga(w, PodaciFormeNaloga{
PodaciStranice: ps,
Nalog: nalog,
Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi,
Greska: "Datum garancije ne može biti pre datuma prijema.",
Izmena: false,
})
return
}
// ako nije izabran postojeći klijent, eventualno kreiraj novog iz polja forme // ako nije izabran postojeći klijent, eventualno kreiraj novog iz polja forme
if greska := h.mozdaKreirajKlijenta(r.Context(), r, &nalog); greska != "" { if greska := h.mozdaKreirajKlijenta(r.Context(), r, &nalog); greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
@@ -272,6 +297,12 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
return return
} }
garancijaDefault := ""
if d := defaultGarancija(nalog.DatumPrijema, podesavanja); d != nil {
garancijaDefault = d.Format("2006-01-02")
}
// stanje „bez garancije" čitamo iz baze pre nego što popunimo default za prikaz
bezGarancije := nalog.GarancijaDo == nil
if nalog.GarancijaDo == nil { if nalog.GarancijaDo == nil {
nalog.GarancijaDo = defaultGarancija(nalog.DatumPrijema, podesavanja) nalog.GarancijaDo = defaultGarancija(nalog.DatumPrijema, podesavanja)
} }
@@ -285,6 +316,8 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
Tehnicari: tehnicari, Tehnicari: tehnicari,
SviStatusi: model.SviStatusi, SviStatusi: model.SviStatusi,
Izmena: true, Izmena: true,
GarancijaDefault: garancijaDefault,
BezGarancije: bezGarancije,
}) })
} }
@@ -346,6 +379,33 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
nalog.CenaDo = stari.CenaDo nalog.CenaDo = stari.CenaDo
nalog.CenaKonacna = stari.CenaKonacna nalog.CenaKonacna = stari.CenaKonacna
nalog.DatumZavrsetka = stari.DatumZavrsetka nalog.DatumZavrsetka = stari.DatumZavrsetka
nalog.PredvidjenDatum = stari.PredvidjenDatum
nalog.DatumPrijema = stari.DatumPrijema
}
// garancija ne sme da bude pre datuma prijema
if nalog.GarancijaDo != nil && garancijaPrePrijema(*nalog.GarancijaDo, nalog.DatumPrijema) {
greska := "Datum garancije ne može biti pre datuma prijema."
if autosave {
http.Error(w, greska, http.StatusBadRequest)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Izmeni nalog"
h.renderujFormuNaloga(w, PodaciFormeNaloga{
PodaciStranice: ps,
Nalog: nalog,
Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi,
Greska: greska,
Izmena: true,
})
return
} }
// ako nije izabran postojeći klijent, eventualno kreiraj novog iz polja forme // ako nije izabran postojeći klijent, eventualno kreiraj novog iz polja forme
@@ -471,9 +531,18 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
return return
} }
// predviđen datum popravke je izvedena vrednost (samo prikaz): // predviđen datum popravke: ako postoji ručni override (iz baze) koristi se,
// datum prijema + broj dana iz podešavanja (predvidjen_rok_dana) // inače izvedeni default (datum prijema + rok iz podešavanja)
if nalog.PredvidjenDatum == nil {
nalog.PredvidjenDatum = defaultPredvidjenDatum(nalog.DatumPrijema, podesavanja) nalog.PredvidjenDatum = defaultPredvidjenDatum(nalog.DatumPrijema, podesavanja)
}
// podrazumevana garancija (za dugme „Podrazumevano" u popup-u) i stanje „bez garancije" iz baze
garancijaDefault := ""
if d := defaultGarancija(nalog.DatumPrijema, podesavanja); d != nil {
garancijaDefault = d.Format("2006-01-02")
}
bezGarancije := nalog.GarancijaDo == nil
klijentNaziv := "" klijentNaziv := ""
if nalog.KlijentID != nil { if nalog.KlijentID != nil {
@@ -491,6 +560,12 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
} }
} }
// lista tehničara za izbor u popup-u „Promeni tehničara"
tehnicari, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
slog.Error("greška pri učitavanju tehničara", "error", err)
}
delovi, err := h.deloviSaPotrazivanima(r.Context(), id) delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil { if err != nil {
slog.Error("greška pri učitavanju delova", "error", err) slog.Error("greška pri učitavanju delova", "error", err)
@@ -546,6 +621,9 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
Nalog: *nalog, Nalog: *nalog,
KlijentNaziv: klijentNaziv, KlijentNaziv: klijentNaziv,
TehnicarNaziv: tehnicarNaziv, TehnicarNaziv: tehnicarNaziv,
Tehnicari: tehnicari,
GarancijaDefault: garancijaDefault,
BezGarancije: bezGarancije,
ServisniDelovi: delovi, ServisniDelovi: delovi,
ServisniRadovi: radovi, ServisniRadovi: radovi,
Artikli: artikli, Artikli: artikli,
@@ -943,6 +1021,14 @@ func defaultPredvidjenDatum(datumPrijema time.Time, podesavanja map[string]strin
return &t return &t
} }
// garancijaPrePrijema vraća true ako je datum garancije raniji od datuma prijema (po danu).
// Garancija ne sme da bude pre prijema uređaja.
func garancijaPrePrijema(garancija, prijem time.Time) bool {
g := time.Date(garancija.Year(), garancija.Month(), garancija.Day(), 0, 0, 0, 0, time.UTC)
p := time.Date(prijem.Year(), prijem.Month(), prijem.Day(), 0, 0, 0, 0, time.UTC)
return g.Before(p)
}
// parseOpcionuCenu pretvara string u *float64 — prazno polje ili neispravna vrednost vraća nil // parseOpcionuCenu pretvara string u *float64 — prazno polje ili neispravna vrednost vraća nil
func parseOpcionuCenu(s string) *float64 { func parseOpcionuCenu(s string) *float64 {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
@@ -1368,6 +1454,9 @@ func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return return
} }
// ista logika kao u formi „Izmeni nalog" (parseFormuNaloga): popunjen datum se čuva,
// prazno ili „bez garancije" → bez garancije. Podrazumevanu vrednost postavlja dugme
// „Podrazumevano" eksplicitno (popuni input), pa ovde nema posebne default grane.
var garancijaDo *time.Time var garancijaDo *time.Time
if r.FormValue("bez_garancije") != "1" { if r.FormValue("bez_garancije") != "1" {
if gd := strings.TrimSpace(r.FormValue("garancija_do")); gd != "" { if gd := strings.TrimSpace(r.FormValue("garancija_do")); gd != "" {
@@ -1377,19 +1466,13 @@ func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {
return return
} }
garancijaDo = &t garancijaDo = &t
} else {
// odštiklirano „bez garancije" bez izabranog datuma → vrati default
nalog, err := h.ServisRepo.DohvatiID(r.Context(), id)
if err != nil {
slog.Error("greška pri dohvatanju naloga za garanciju", "id", id, "error", err)
} else {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
slog.Error("greška pri učitavanju podešavanja za garanciju", "error", err)
} else {
garancijaDo = defaultGarancija(nalog.DatumPrijema, podesavanja)
} }
} }
// garancija ne sme da bude pre datuma prijema
if garancijaDo != nil {
if nalog, e := h.ServisRepo.DohvatiID(r.Context(), id); e == nil && nalog != nil && garancijaPrePrijema(*garancijaDo, nalog.DatumPrijema) {
http.Error(w, "Datum garancije ne može biti pre datuma prijema.", http.StatusBadRequest)
return
} }
} }
if err := h.ServisRepo.AzurirajGaranciju(r.Context(), id, garancijaDo); err != nil { if err := h.ServisRepo.AzurirajGaranciju(r.Context(), id, garancijaDo); err != nil {
@@ -1400,6 +1483,69 @@ func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther) http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
} }
// AzurirajPredvidjenDatum postavlja ručni override predviđenog datuma popravke;
// prazno polje vraća nalog na izvedeni default (prijem + rok iz podešavanja).
func (h *Handler) AzurirajPredvidjenDatum(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
var predvidjenDatum *time.Time
if pd := strings.TrimSpace(r.FormValue("predvidjen_datum")); pd != "" {
t, err := time.Parse("2006-01-02", pd)
if err != nil {
http.Error(w, "Neispravan predviđen datum", http.StatusBadRequest)
return
}
predvidjenDatum = &t
}
if err := h.ServisRepo.AzurirajPredvidjenDatum(r.Context(), id, predvidjenDatum); err != nil {
slog.Error("greška pri ažuriranju predviđenog datuma", "id", id, "error", err)
http.Error(w, "Greška pri ažuriranju predviđenog datuma", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// AzurirajTehnicar menja dodeljenog tehničara na nalogu; prazna vrednost → nedodeljen.
func (h *Handler) AzurirajTehnicar(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
var tehnicarID *int64
if t := strings.TrimSpace(r.FormValue("tehnicar_id")); t != "" {
v, err := strconv.ParseInt(t, 10, 64)
if err != nil {
http.Error(w, "Neispravan tehničar", http.StatusBadRequest)
return
}
tehnicarID = &v
}
if err := h.ServisRepo.AzurirajTehnicar(r.Context(), id, tehnicarID); err != nil {
slog.Error("greška pri ažuriranju tehničara", "id", id, "error", err)
http.Error(w, "Greška pri ažuriranju tehničara", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// SacuvajNapomenuKlijentu ažurira napomenu namenjenu klijentu na nalogu // SacuvajNapomenuKlijentu ažurira napomenu namenjenu klijentu na nalogu
func (h *Handler) SacuvajNapomenuKlijentu(w http.ResponseWriter, r *http.Request) { func (h *Handler) SacuvajNapomenuKlijentu(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
@@ -0,0 +1,4 @@
-- predviđen datum popravke: ručni override roka za pojedinačni nalog.
-- Ako je popunjen, koristi se on; ako je NULL, prikaz računa izvedeni default
-- (datum_prijema + predvidjen_rok_dana iz podešavanja).
ALTER TABLE servisni_nalozi ADD COLUMN predvidjen_datum DATE;
+16
View File
@@ -522,6 +522,22 @@ body {
} }
.btn-primarno-malo:hover { opacity: 0.85; } .btn-primarno-malo:hover { opacity: 0.85; }
/* sitno diskretno dugme „Promeni" pored vrednosti u detaljima naloga */
.izmeni-polje {
margin-left: 6px;
font-size: 11px;
padding: 1px 7px;
border: 0.5px solid var(--ivica);
border-radius: 6px;
background: var(--kartica);
color: var(--tekst-sporedni);
cursor: pointer;
white-space: nowrap;
vertical-align: middle;
transition: color 0.15s, border-color 0.15s;
}
.izmeni-polje:hover { color: var(--tekst-glavni); border-color: var(--sb-akcent); }
/* sekundarno dugme (Odustani) */ /* sekundarno dugme (Odustani) */
.btn-sekundarno { .btn-sekundarno {
display: inline-flex; display: inline-flex;
+103 -5
View File
@@ -70,16 +70,66 @@
<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>
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.DatumPrijema.Format "02.01.2006. u 15:04"}}</div> <div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.DatumPrijema.Format "02.01.2006. u 15:04"}}</div>
</div> </div>
{{if .Nalog.PredvidjenDatum}}
<div> <div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Predviđen datum popravke</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Predviđen datum popravke</div>
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.PredvidjenDatum.Format "02.01.2006."}}</div> <div style="font-size:14px;color:var(--tekst-glavni);">
{{if .Nalog.PredvidjenDatum}}{{.Nalog.PredvidjenDatum.Format "02.01.2006."}}{{else}}—{{end}}
{{if index .Dozvole "servis.izmeni"}}
<button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Predviđen datum popravke</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<form method="POST" action="/servis/{{.Nalog.ID}}/predvidjen-datum" data-refresh="#nalog-zaglavlje" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div>
<label class="polje-labela">Datum</label>
<input type="date" name="predvidjen_datum"
value="{{if .Nalog.PredvidjenDatum}}{{.Nalog.PredvidjenDatum.Format "2006-01-02"}}{{end}}"
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
<div class="pomocni-tekst" style="margin-top:6px;">Podrazumevano = automatski (datum prijema + rok iz podešavanja).</div>
</div> </div>
<button type="button" class="btn-sekundarno" style="align-self:flex-start;"
onclick="var f=this.closest('form'); f.querySelector('[name=predvidjen_datum]').value=''; f.requestSubmit();">Podrazumevano</button>
</form>
</dialog>
{{end}} {{end}}
</div>
</div>
<div> <div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija do</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija do</div>
<div style="font-size:14px;{{if .Nalog.GarancijaDo}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}"> <div style="font-size:14px;{{if .Nalog.GarancijaDo}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}">
{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "02.01.2006."}}{{else}}—{{end}} {{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "02.01.2006."}}{{else}}—{{end}}
{{if index .Dozvole "servis.izmeni"}}
<button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Garancija do</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<form method="POST" action="/servis/{{.Nalog.ID}}/garancija" data-refresh="#nalog-zaglavlje" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div>
<label class="polje-labela">Garancija do</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="date" name="garancija_do"
value="{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "2006-01-02"}}{{else}}{{.GarancijaDefault}}{{end}}"
min="{{.Nalog.DatumPrijema.Format "2006-01-02"}}"
{{if .BezGarancije}}disabled{{end}}
style="flex:1;min-width:0;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;{{if .BezGarancije}}opacity:.5;{{end}}">
<label class="tip-opcija{{if .BezGarancije}} tip-opcija-akt{{end}}" style="white-space:nowrap;">
<input type="checkbox" name="bez_garancije" value="1"{{if .BezGarancije}} checked{{end}} style="margin:0;width:auto;"
onchange="var d=this.closest('div').querySelector('[name=garancija_do]'); d.disabled=this.checked; d.style.opacity=this.checked?'.5':''; this.closest('label').classList.toggle('tip-opcija-akt',this.checked);">
Bez garancije
</label>
</div>
</div>
<button type="button" class="btn-sekundarno" style="align-self:flex-start;"
onclick="var f=this.closest('form'); var d=f.querySelector('[name=garancija_do]'); d.disabled=false; d.style.opacity=''; d.value='{{.GarancijaDefault}}'; var c=f.querySelector('[name=bez_garancije]'); c.checked=false; c.closest('label').classList.remove('tip-opcija-akt'); f.requestSubmit();">Podrazumevano</button>
</form>
</dialog>
{{end}}
</div> </div>
</div> </div>
<div> <div>
@@ -88,12 +138,34 @@
{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}} {{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}}
</div> </div>
</div> </div>
{{if .TehnicarNaziv}}
<div> <div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Tehničar</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Tehničar</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{.TehnicarNaziv}}</div> <div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">
</div> {{if .TehnicarNaziv}}{{.TehnicarNaziv}}{{else}}<span style="color:var(--tekst-sporedni);font-weight:normal;">— nedodeljen —</span>{{end}}
{{if index .Dozvole "servis.izmeni"}}
<button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button>
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
<form method="dialog" class="premesti-zaglavlje">
<h3>Tehničar</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form>
<form method="POST" action="/servis/{{.Nalog.ID}}/tehnicar" data-refresh="#nalog-zaglavlje" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div>
<label class="polje-labela">Tehničar</label>
<select name="tehnicar_id"
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
<option value="">— nedodeljen —</option>
{{range .Tehnicari}}
<option value="{{.ID}}"{{if eq .ID $.Nalog.TehnicarIDVrednost}} selected{{end}}>{{.KorisnickoIme}}</option>
{{end}} {{end}}
</select>
</div>
</form>
</dialog>
{{end}}
</div>
</div>
</div> </div>
</div> </div>
@@ -480,5 +552,31 @@
}); });
} }
})(); })();
// Popup polja (predviđen datum, garancija, tehničar): nema dugmeta „Sačuvaj" —
// snima se na zatvaranje modala, i to samo ako je vrednost promenjena.
// Početno stanje forme pamtimo pri otvaranju; na „close" upoređujemo i, ako se
// razlikuje, šaljemo formu (ntech AJAX presretač osveži #nalog-zaglavlje).
window.ntechOtvoriModal = function (btn) {
var d = btn.nextElementSibling;
if (!d || d.tagName !== 'DIALOG') return;
var forma = d.querySelector('form[method="POST"], form[method="post"]');
if (forma) forma.dataset.poc = new URLSearchParams(new FormData(forma)).toString();
d.showModal();
};
if (!window._ntechModalSaveDodato) {
window._ntechModalSaveDodato = true;
// 'close' ne baburi — slušamo u capture fazi na dokumentu (hvata i modale ubačene swap-om)
document.addEventListener('close', function (e) {
var d = e.target;
if (!d || d.tagName !== 'DIALOG') return;
var forma = d.querySelector('form[method="POST"], form[method="post"]');
if (!forma || forma.dataset.poc === undefined) return;
var sad = new URLSearchParams(new FormData(forma)).toString();
if (sad !== forma.dataset.poc) {
if (forma.requestSubmit) forma.requestSubmit(); else forma.submit();
}
}, true);
}
</script> </script>
{{end}} {{end}}
+12 -10
View File
@@ -281,22 +281,24 @@
placeholder="0" style="width:100%;"> placeholder="0" style="width:100%;">
</div> </div>
{{if .Izmena}} {{if .Izmena}}
<!-- garancija — uređuje se ovde; „Bez garancije" isključuje datum --> <!-- garancija — isti izgled kao popup „Garancija do" u detaljima naloga;
<div x-data="{ bez: {{if .Nalog.GarancijaDo}}false{{else}}true{{end}} }"> „Bez garancije" isključuje datum, „Podrazumevano" vraća prijem + meseci -->
<div>
<label class="polje-labela">Garancija do</label> <label class="polje-labela">Garancija do</label>
<div style="display:flex;align-items:center;gap:10px;"> <div style="display:flex;align-items:center;gap:10px;">
<input type="date" name="garancija_do" <input type="date" name="garancija_do"
value="{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "2006-01-02"}}{{end}}" value="{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "2006-01-02"}}{{else}}{{.GarancijaDefault}}{{end}}"
:disabled="bez" :style="bez ? 'opacity:.5;' : ''" min="{{.Nalog.DatumPrijema.Format "2006-01-02"}}"
style="flex:1;min-width:0;"> {{if .BezGarancije}}disabled{{end}}
<label class="tip-opcija" :class="bez ? 'tip-opcija-akt' : ''" style="flex:1;min-width:0;{{if .BezGarancije}}opacity:.5;{{end}}">
style="white-space:nowrap;"> <label class="tip-opcija{{if .BezGarancije}} tip-opcija-akt{{end}}" style="white-space:nowrap;">
<input type="checkbox" x-model="bez" style="margin:0;width:auto;"> <input type="checkbox" name="bez_garancije" value="1"{{if .BezGarancije}} checked{{end}} style="margin:0;width:auto;"
onchange="var d=this.closest('div').querySelector('[name=garancija_do]'); d.disabled=this.checked; d.style.opacity=this.checked?'.5':''; this.closest('label').classList.toggle('tip-opcija-akt',this.checked);">
Bez garancije Bez garancije
</label> </label>
</div> </div>
<!-- skriveni flag: server preskače datum kada je 1 --> <button type="button" class="btn-sekundarno" style="margin-top:8px;"
<input type="hidden" name="bez_garancije" :value="bez ? '1' : '0'"> onclick="var f=this.closest('form'); var d=f.querySelector('[name=garancija_do]'); d.disabled=false; d.style.opacity=''; d.value='{{.GarancijaDefault}}'; var c=f.querySelector('[name=bez_garancije]'); c.checked=false; c.closest('label').classList.remove('tip-opcija-akt'); d.dispatchEvent(new Event('change',{bubbles:true}));">Podrazumevano</button>
</div> </div>
{{end}} {{end}}
</div> </div>