Servis: garancija kao trajanje u danima + Tehničar → Serviser

- Garancija se u detaljima prikazuje kao trajanje na srpskom (npr. „1 mesec i 15 dana",
  „2 meseca", „20 dana"); helper garancijaTekst + srpska deklinacija
- Nova kolona garancija_dana (trajanje od završetka); garancija_do ostaje za fazu Završeno
- Modal: textbox „Trajanje garancije (dana)" umesto kalendara; Podrazumevano i Bez garancije
- Nova ruta/handler AzurirajGarancijaDana; forma izmene čuva trajanje (ne briše)
- Podešavanja → Servis: rok garancije prebačen iz meseci u dane (servis_garancija_dana, default 60)
- „Garancija do" uklonjena iz forme Izmeni nalog (kod arhiviran u docs/garancija-buduca-logika.md);
  SacuvajIzmenaNaloga čuva garanciju iz baze da je auto-snimanje ne pregazi
- Prikazni tekst „Tehničar" → „Serviser" svuda (identifikatori Tehnicar/tehnicar_id netaknuti)
This commit is contained in:
2026-06-23 02:35:49 +02:00
parent bd62a89332
commit ccb3f9d748
16 changed files with 255 additions and 83 deletions
+1
View File
@@ -360,6 +360,7 @@ func main() {
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}/garancija", h.AzurirajGaranciju)
r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija-dana", h.AzurirajGarancijaDana)
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)
+66
View File
@@ -0,0 +1,66 @@
# Garancija — buduća logika (arhiva koda)
## Plan (zašto)
Trenutno se „Garancija do" unosi kao konkretan datum. To nema smisla dok je uređaj
tek primljen (status **Primljeno**) — popravka još nije ni počela, pa ni datum
garancije nije poznat.
**Buduća logika:**
- Garancija se izražava kao **broj dana od datuma završetka** (podesivo, kao i sada
preko `servis_garancija_meseci` / mogući novi `servis_garancija_dana`).
- Dok je nalog u radu (Primljeno, U popravci…), klijentu se prikazuje samo
**podrazumevana garancija** kao informacija (npr. „garancija: 12 meseci od završetka").
- **Pravi datum garancije** (`garancija_do`) upisuje se tek kad nalog pređe u
**Završeno**`datum_zavrsetka + garantni period`.
Zbog toga je „Garancija do" uklonjena iz forme **Izmeni nalog** (vidi commit), a
ovde je arhiviran kod da se kasnije ponovo upotrebi / prilagodi.
## Arhivirani kod — „Garancija do" blok iz `servis_forma.html` (Izmeni nalog)
```html
{{if .Izmena}}
<!-- garancija — isti izgled kao popup „Garancija do" u detaljima naloga;
„Bez garancije" isključuje datum, „Podrazumevano" vraća prijem + meseci -->
<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;{{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>
<button type="button" class="btn-sekundarno" style="margin-top:8px;"
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>
{{end}}
```
## Backend koji OSTAJE (i dalje koristi modal „Garancija do" u detaljima)
Nije uklonjeno — ovo su delovi koje treba znati pri ponovnoj upotrebi:
- `internal/handler/servis.go`:
- `defaultGarancija(datumPrijema, podesavanja)` — prijem + `servis_garancija_meseci`.
Za buduću logiku zameniti osnovicu `datum_zavrsetka` umesto `datum_prijema`.
- `garancijaPrePrijema(garancija, prijem)` — validacija da garancija nije pre prijema.
- `AzurirajGaranciju` (ruta `POST /servis/{id}/garancija`) — snima `garancija_do`,
prazno / „bez garancije" → NULL.
- U `parseFormuNaloga`: blok koji čita `garancija_do` + `bez_garancije`.
- `BezGarancije` flag (čita NULL stanje iz baze) i `GarancijaDefault` (prijem + meseci)
u `PodaciDetaljiNaloga` / `PodaciFormeNaloga`.
- `internal/db/sqlite/servis.go`: `AzurirajGaranciju` repo metoda; kolona `garancija_do`.
- Podešavanje: `servis_garancija_meseci` (Podešavanja → Servis).
## TODO kad se vraća
1. Dodati prikaz „podrazumevana garancija" (informativno) na nalogu u statusu Primljeno.
2. Pri prelasku u **Završeno** automatski izračunati `garancija_do = datum_zavrsetka + period`.
3. Razmotriti period u danima umesto/pored meseci.
+1
View File
@@ -192,6 +192,7 @@ type ServisRepository interface {
Izmeni(ctx context.Context, n *model.ServisniNalog) error
AzurirajStatus(ctx context.Context, id int64, status string) error
AzurirajGaranciju(ctx context.Context, id int64, garancijaDo *time.Time) error
AzurirajGarancijaDana(ctx context.Context, id int64, dana *int) 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
+28 -10
View File
@@ -57,7 +57,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
SELECT
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.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.javni_token,
COALESCE(kp.naziv, '') AS klijent_naziv
FROM servisni_nalozi sn
@@ -104,7 +104,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
SELECT
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, datum_prijema, datum_zavrsetka, predvidjen_datum,
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, javni_token
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, `
INSERT INTO servisni_nalozi
(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, predvidjen_datum,
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, garancija_dana, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje, n.Status,
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), nullTime(n.PredvidjenDatum),
nullTime(n.GarancijaDo), nullInt(n.GarancijaDana), nullTime(n.DatumZavrsetka), nullTime(n.PredvidjenDatum),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.DatumPrijema, token,
)
@@ -156,7 +156,7 @@ func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*mode
SELECT
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, datum_prijema, datum_zavrsetka, predvidjen_datum,
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, javni_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
klijent_id = ?, tehnicar_id = ?, uredjaj = ?, serijski_broj = ?, opis_kvara = ?, trazene_nadogradnje = ?,
status = ?, cena_od = ?, cena_do = ?, cena_konacna = ?,
avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?, predvidjen_datum = ?,
avans = ?, napomena = ?, garancija_do = ?, garancija_dana = ?, datum_zavrsetka = ?, predvidjen_datum = ?,
ostecenja = ?, pin_uredjaja = ?, pribor = ?
WHERE id = ?`,
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),
nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), nullTime(n.PredvidjenDatum),
nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullInt(n.GarancijaDana), nullTime(n.DatumZavrsetka), nullTime(n.PredvidjenDatum),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.ID,
)
@@ -229,6 +229,19 @@ func (r *ServisRepo) AzurirajGaranciju(ctx context.Context, id int64, garancijaD
return nil
}
// AzurirajGarancijaDana postavlja trajanje garancije u danima (od završetka radova).
// dana == nil ili 0 → bez garancije.
func (r *ServisRepo) AzurirajGarancijaDana(ctx context.Context, id int64, dana *int) error {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET garancija_dana = ? WHERE id = ?",
nullInt(dana), id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajGarancijaDana: %w", err)
}
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 {
@@ -251,7 +264,7 @@ func (r *ServisRepo) AzurirajPredvidjenDatum(ctx context.Context, id int64, pred
return nil
}
// AzurirajTehnicar postavlja ili uklanja dodeljenog tehničara na nalogu.
// AzurirajTehnicar postavlja ili uklanja dodeljenog servisera na nalogu.
// tehnicarID == nil → nedodeljen.
func (r *ServisRepo) AzurirajTehnicar(ctx context.Context, id int64, tehnicarID *int64) error {
_, err := r.db.ExecContext(ctx,
@@ -359,11 +372,12 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, javniToken sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime
var garancijaDana sql.NullInt64
args := []any{
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
&avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &javniToken,
}
@@ -417,6 +431,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := predvidjenDatum.Time
n.PredvidjenDatum = &v
}
if garancijaDana.Valid {
v := int(garancijaDana.Int64)
n.GarancijaDana = &v
}
n.JavniToken = javniToken.String
return nil
+9
View File
@@ -20,6 +20,15 @@ func nullInt64(v *int64) sql.NullInt64 {
return sql.NullInt64{Int64: *v, Valid: true}
}
// nullInt pretvara *int pokazivač u sql.NullInt64 —
// koristi se za opciona celobrojna polja (npr. trajanje garancije u danima)
func nullInt(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
// nullFloat64 pretvara *float64 pokazivač u sql.NullFloat64 —
// koristi se za opciona numerička polja kao što su cene
func nullFloat64(v *float64) sql.NullFloat64 {
+2
View File
@@ -68,6 +68,8 @@ var sablonskeFunkcije = template.FuncMap{
},
// telefon formatira srpski broj telefona radi lakšeg čitanja: "0641234567" → "064 123 4567"
"telefon": formatirajTelefon,
// garancijaTekst prikazuje trajanje garancije (dani) na srpskom: 45 → „1 mesec i 15 dana"
"garancijaTekst": garancijaTekst,
// json serijalizuje vrednost u JSON za ugradnju u <script> (npr. Alpine x-data);
// template.JS sprečava da html/template dodatno escapuje rezultat
"json": func(v any) (template.JS, error) {
+7 -7
View File
@@ -49,7 +49,7 @@ type PodaciPodesavanja struct {
BackupIntervalSati string
BackupBrojKopija string
KalkulacijaMarza string
ServisGarancijaMeseci string
ServisGarancijaDana string
PredracunRokDana string
PredvidjenRokDana string
LoginPozadina string
@@ -366,15 +366,15 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
}
}
// podrazumevani rok garancije za servis (meseci, 0120)
if v := strings.TrimSpace(r.FormValue("servis_garancija_meseci")); v != "" {
// podrazumevana garancija za servis (dani, 03650)
if v := strings.TrimSpace(r.FormValue("servis_garancija_dana")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 || n > 120 {
middleware.SetFlash(w, r, h.DB, "greska", "Rok garancije mora biti broj između 0 i 120 meseci.")
if err != nil || n < 0 || n > 3650 {
middleware.SetFlash(w, r, h.DB, "greska", "Garancija mora biti broj između 0 i 3650 dana.")
http.Redirect(w, r, sledeci, http.StatusSeeOther)
return
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_garancija_meseci", strconv.Itoa(n)); err != nil {
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_garancija_dana", strconv.Itoa(n)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return
}
@@ -779,7 +779,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"),
BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"),
ServisGarancijaDana: vrednostIliDefault(podesavanja, "servis_garancija_dana", "60"),
PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"),
PredvidjenRokDana: vrednostIliDefault(podesavanja, "predvidjen_rok_dana", "15"),
}, nil
+105 -15
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"strconv"
@@ -148,7 +149,7 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) {
tehnicari, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
http.Error(w, "Greška pri učitavanju tehničara", http.StatusInternalServerError)
http.Error(w, "Greška pri učitavanju servisera", http.StatusInternalServerError)
return
}
@@ -293,7 +294,7 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
tehnicari, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
http.Error(w, "Greška pri učitavanju tehničara", http.StatusInternalServerError)
http.Error(w, "Greška pri učitavanju servisera", http.StatusInternalServerError)
return
}
@@ -381,6 +382,8 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
nalog.DatumZavrsetka = stari.DatumZavrsetka
nalog.PredvidjenDatum = stari.PredvidjenDatum
nalog.DatumPrijema = stari.DatumPrijema
nalog.GarancijaDo = stari.GarancijaDo // garancija se uređuje u detaljima, ne u formi
nalog.GarancijaDana = stari.GarancijaDana // isto — trajanje garancije se ne dira u formi
}
// garancija ne sme da bude pre datuma prijema
@@ -537,12 +540,9 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
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
// podrazumevano trajanje garancije u danima (dugme „Podrazumevano") i stanje „bez garancije" iz baze
garancijaDefault := strconv.Itoa(defaultGarancijaDana(podesavanja))
bezGarancije := nalog.GarancijaDana == nil || *nalog.GarancijaDana <= 0
klijentNaziv := ""
if nalog.KlijentID != nil {
@@ -560,10 +560,10 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
}
}
// lista tehničara za izbor u popup-u „Promeni tehničara"
// lista servisera za izbor u popup-u „Promeni servisera"
tehnicari, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
slog.Error("greška pri učitavanju tehničara", "error", err)
slog.Error("greška pri učitavanju servisera", "error", err)
}
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
@@ -905,7 +905,7 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
}
}
// opcioni tehničar
// opcioni serviser
if tidStr := r.FormValue("tehnicar_id"); tidStr != "" {
if tid, err := strconv.ParseInt(tidStr, 10, 64); err == nil {
nalog.TehnicarID = &tid
@@ -1021,6 +1021,60 @@ func defaultPredvidjenDatum(datumPrijema time.Time, podesavanja map[string]strin
return &t
}
// srpskiPlural vraća odgovarajući oblik reči za dati broj (1 / 24 / 5+).
func srpskiPlural(n int, jedan, par, mnogo string) string {
if n < 0 {
n = -n
}
d, dd := n%10, n%100
switch {
case d == 1 && dd != 11:
return jedan
case d >= 2 && d <= 4 && (dd < 12 || dd > 14):
return par
default:
return mnogo
}
}
// garancijaUTekst formatira trajanje garancije (u danima) na srpskom:
// 20 → „20 dana", 45 → „1 mesec i 15 dana", 60 → „2 meseca".
func garancijaUTekst(dana int) string {
if dana <= 0 {
return "bez garancije"
}
m := dana / 30
d := dana % 30
mesecRec := srpskiPlural(m, "mesec", "meseca", "meseci")
danRec := srpskiPlural(d, "dan", "dana", "dana")
switch {
case m > 0 && d > 0:
return fmt.Sprintf("%d %s i %d %s", m, mesecRec, d, danRec)
case m > 0:
return fmt.Sprintf("%d %s", m, mesecRec)
default:
return fmt.Sprintf("%d %s", d, danRec)
}
}
// garancijaTekst je šablonski helper: trajanje garancije u danima → tekst, ili „—" za prazno.
func garancijaTekst(dana *int) string {
if dana == nil || *dana <= 0 {
return "—"
}
return garancijaUTekst(*dana)
}
// defaultGarancijaDana vraća podrazumevano trajanje garancije u danima iz podešavanja
// (servis_garancija_dana, podrazumevano 60).
func defaultGarancijaDana(podesavanja map[string]string) int {
dana, err := strconv.Atoi(vrednostIliDefault(podesavanja, "servis_garancija_dana", "60"))
if err != nil || dana < 0 {
return 60
}
return dana
}
// 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 {
@@ -1483,6 +1537,42 @@ func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// AzurirajGarancijaDana postavlja trajanje garancije u danima (od završetka radova);
// „bez garancije" ili prazno/0 → bez garancije.
func (h *Handler) AzurirajGarancijaDana(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 dana *int
if r.FormValue("bez_garancije") != "1" {
if s := strings.TrimSpace(r.FormValue("garancija_dana")); s != "" {
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
http.Error(w, "Garancija mora biti ceo broj dana.", http.StatusBadRequest)
return
}
if n > 0 {
dana = &n
}
}
}
if err := h.ServisRepo.AzurirajGarancijaDana(r.Context(), id, dana); err != nil {
slog.Error("greška pri ažuriranju garancije (dana)", "id", id, "error", err)
http.Error(w, "Greška pri ažuriranju garancije", http.StatusInternalServerError)
return
}
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) {
@@ -1515,7 +1605,7 @@ func (h *Handler) AzurirajPredvidjenDatum(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// AzurirajTehnicar menja dodeljenog tehničara na nalogu; prazna vrednost → nedodeljen.
// AzurirajTehnicar menja dodeljenog servisera 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
@@ -1533,14 +1623,14 @@ func (h *Handler) AzurirajTehnicar(w http.ResponseWriter, r *http.Request) {
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)
http.Error(w, "Neispravan serviser", 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)
slog.Error("greška pri ažuriranju servisera", "id", id, "error", err)
http.Error(w, "Greška pri ažuriranju servisera", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
+1
View File
@@ -40,6 +40,7 @@ type ServisniNalog struct {
Avans *float64
Napomena string
GarancijaDo *time.Time
GarancijaDana *int // trajanje garancije u danima (od završetka); nil/0 = bez garancije
DatumPrijema time.Time
PredvidjenDatum *time.Time
DatumZavrsetka *time.Time
+4
View File
@@ -0,0 +1,4 @@
-- garancija_dana: trajanje garancije u danima (računato od datuma završetka radova).
-- Unosi se dok je uređaj u radu; pravi datum (garancija_do) se popunjava kad nalog
-- pređe u „Završeno" (datum_zavrsetka + garancija_dana). NULL ili 0 = bez garancije.
ALTER TABLE servisni_nalozi ADD COLUMN garancija_dana INTEGER;
@@ -22,14 +22,15 @@
<div class="kolona" style="gap:16px;">
<div style="max-width:280px;">
<label for="servis_garancija_meseci" class="polje-labela">
Podrazumevani rok garancije (meseci)
<label for="servis_garancija_dana" class="polje-labela">
Podrazumevana garancija (dana)
</label>
<input type="number" id="servis_garancija_meseci" name="servis_garancija_meseci"
min="0" max="120" value="{{.ServisGarancijaMeseci}}"
<input type="number" id="servis_garancija_dana" name="servis_garancija_dana"
min="0" max="3650" value="{{.ServisGarancijaDana}}"
style="width:100%;">
<div class="pomocni-tekst" style="margin-top:6px;">
Automatski se upisuje pri prijemu uređaja. Vrednost 0 znači bez garancije.
Trajanje garancije u danima (računato od završetka radova). Dugme „Podrazumevano"
na nalogu koristi ovu vrednost. 0 znači bez garancije.
</div>
</div>
+15 -15
View File
@@ -98,35 +98,35 @@
</div>
</div>
<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}}">
{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "02.01.2006."}}{{else}}—{{end}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija</div>
<div style="font-size:14px;{{if not .BezGarancije}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}">
{{garancijaTekst .Nalog.GarancijaDana}}
{{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>
<h3>Garancija</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;">
<form method="POST" action="/servis/{{.Nalog.ID}}/garancija-dana" 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>
<label class="polje-labela">Trajanje garancije (dana)</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"}}"
<input type="number" name="garancija_dana" min="0" step="1"
value="{{if .BezGarancije}}{{.GarancijaDefault}}{{else}}{{.Nalog.GarancijaDana}}{{end}}"
{{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);">
onchange="var d=this.closest('div').querySelector('[name=garancija_dana]'); 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 class="pomocni-tekst" style="margin-top:6px;">Računa se od datuma završetka radova.</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>
onclick="var f=this.closest('form'); var d=f.querySelector('[name=garancija_dana]'); 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}}
@@ -139,20 +139,20 @@
</div>
</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;">Serviser</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{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>
<h3>Serviser</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>
<label class="polje-labela">Serviser</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>
@@ -553,7 +553,7 @@
}
})();
// Popup polja (predviđen datum, garancija, tehničar): nema dugmeta „Sačuvaj" —
// Popup polja (predviđen datum, garancija, serviser): 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).
+5 -26
View File
@@ -100,11 +100,11 @@
<!-- ===================== KARTICA: TEHNIČAR (izmena) / KLIJENT I TEHNIČAR (novi) ===================== -->
{{if .Izmena}}
<!-- pri izmeni klijent se ne menja (vezan pri prijemu) — ostaje samo tehničar -->
<!-- pri izmeni klijent se ne menja (vezan pri prijemu) — ostaje samo serviser -->
<div class="kartica forma-kartica animiraj" style="margin-bottom:20px;">
<div class="sekcija-naslov">Tehničar</div>
<div class="sekcija-naslov">Serviser</div>
<div>
<label class="polje-labela">Tehničar</label>
<label class="polje-labela">Serviser</label>
<select name="tehnicar_id" style="width:100%;">
<option value="">— nedodeljen —</option>
{{range .Tehnicari}}
@@ -252,9 +252,9 @@
</template>
</div>
<!-- tehničar -->
<!-- serviser -->
<div>
<label class="polje-labela">Tehničar</label>
<label class="polje-labela">Serviser</label>
<select name="tehnicar_id" style="width:100%;">
<option value="">— nedodeljen —</option>
{{range .Tehnicari}}
@@ -280,27 +280,6 @@
value="{{if .Nalog.Avans}}{{.Nalog.Avans}}{{end}}"
placeholder="0" style="width:100%;">
</div>
{{if .Izmena}}
<!-- garancija — isti izgled kao popup „Garancija do" u detaljima naloga;
„Bez garancije" isključuje datum, „Podrazumevano" vraća prijem + meseci -->
<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;{{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>
<button type="button" class="btn-sekundarno" style="margin-top:8px;"
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>
{{end}}
</div>
<div style="margin-top:16px;">
@@ -146,7 +146,7 @@
</div>
{{if .TehnicarNaziv}}
<div>
<div class="polje-labela">Tehničar</div>
<div class="polje-labela">Serviser</div>
<div class="polje-vrednost">{{.TehnicarNaziv}}</div>
</div>
{{end}}
@@ -244,7 +244,7 @@
<!-- potpisi -->
<div class="potpisi">
<div>
<div class="potpis-linija">Predao (tehničar / firma)</div>
<div class="potpis-linija">Predao (serviser / firma)</div>
</div>
<div>
<div class="potpis-linija">Preuzeo (klijent)</div>
+1 -1
View File
@@ -143,7 +143,7 @@
</div>
{{if .TehnicarNaziv}}
<div>
<div class="polje-labela">Tehničar</div>
<div class="polje-labela">Serviser</div>
<div class="polje-vrednost">{{.TehnicarNaziv}}</div>
</div>
{{end}}
+2 -2
View File
@@ -122,7 +122,7 @@
</div>
{{if .TehnicarNaziv}}
<div>
<div class="polje-labela">Tehničar</div>
<div class="polje-labela">Serviser</div>
<div class="polje-vrednost">{{.TehnicarNaziv}}</div>
</div>
{{end}}
@@ -249,7 +249,7 @@
<div class="potpis-linija">Predao klijent</div>
</div>
<div>
<div class="potpis-linija">Primio tehničar</div>
<div class="potpis-linija">Primio serviser</div>
</div>
</div>