Servis: tok dijagnostike/popravke, kartice Utvrđeni kvar i Urađeno, cena dijagnostike

- Cena dijagnostike u Podešavanja → Servis; na nalogu se predlaže iz podešavanja
- Odbijanje popravke: status Završeno + naplata samo dijagnostike (popravka_odbijena),
sklanjaju se Radovi/Delovi, Ukupno = dijagnostika
- Kartica Nalaz dijagnostike preimenovana u Utvrđeni kvar; editabilna samo u dijagnostici, kasnije readonly
- Nova kartica Urađeno (polje uradjeno) — upis dok je U popravci/Čeka delove, posle readonly
- Dugmad statusa: Završi u U popravci; promena statusa osvežava ceo sadržaj
- Predračun dostupan od Primljeno; radni nalog u U dijagnostici/Čeka delove
- Dropdown radova izostavlja već dodate usluge; kartica radova se osvežava pri izmeni
- Javni status: badge U dijagnostici, prikaz utvrđenog kvara, wordwrap naziva u tabeli
- Štampa: prelom između stavki, ponavljanje zaglavlja, završni blok bez preloma
This commit is contained in:
2026-06-23 22:21:12 +02:00
parent a8d5b0e9b4
commit f745f043c5
17 changed files with 494 additions and 106 deletions
+4
View File
@@ -366,6 +366,10 @@ func main() {
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}/nalaz-dijagnostike", h.SacuvajNalazDijagnostike)
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}/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)
+4
View File
@@ -196,6 +196,10 @@ type ServisRepository interface {
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
AzurirajNalazDijagnostike(ctx context.Context, id int64, tekst string) error
AzurirajUradjeno(ctx context.Context, id int64, tekst string) error
AzurirajCenaDijagnostike(ctx context.Context, id int64, cena float64) error
OdbijPopravku(ctx context.Context, id int64, cena float64) error
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
SledeciBroj(ctx context.Context) (string, error)
}
+59 -7
View File
@@ -58,7 +58,7 @@ 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.javni_token,
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token,
COALESCE(kp.naziv, '') AS klijent_naziv
FROM servisni_nalozi sn
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
@@ -105,7 +105,7 @@ 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, javni_token
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token
FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog
@@ -157,7 +157,7 @@ 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, javni_token
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token
FROM servisni_nalozi WHERE javni_token = ?`, token)
var n model.ServisniNalog
@@ -192,13 +192,15 @@ func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
// AzurirajStatus menja samo status naloga; ako nalog prelazi u završno stanje
// i datum_zavrsetka još nije postavljen, automatski ga postavlja na danas.
func (r *ServisRepo) AzurirajStatus(ctx context.Context, id int64, status string) error {
// svaka „normalna" promena statusa poništava oznaku odbijene popravke
// (npr. serviser se predomisli i vrati nalog iz „Završeno" u dijagnostiku/popravku)
var upit string
if status == model.StatusZavrseno || status == model.StatusPreuzeto {
upit = `UPDATE servisni_nalozi SET status = ?,
upit = `UPDATE servisni_nalozi SET status = ?, popravka_odbijena = 0,
datum_zavrsetka = COALESCE(datum_zavrsetka, date('now', 'localtime'))
WHERE id = ?`
} else {
upit = `UPDATE servisni_nalozi SET status = ? WHERE id = ?`
upit = `UPDATE servisni_nalozi SET status = ?, popravka_odbijena = 0 WHERE id = ?`
}
_, err := r.db.ExecContext(ctx, upit, status, id)
if err != nil {
@@ -207,6 +209,19 @@ func (r *ServisRepo) AzurirajStatus(ctx context.Context, id int64, status string
return nil
}
// OdbijPopravku označava da je klijent odbio popravku posle dijagnostike:
// nalog prelazi u „Završeno", upisuje se oznaka i cena dijagnostike (taksa za pregled).
func (r *ServisRepo) OdbijPopravku(ctx context.Context, id int64, cena float64) error {
_, err := r.db.ExecContext(ctx, `UPDATE servisni_nalozi
SET status = ?, popravka_odbijena = 1, cena_dijagnostike = ?,
datum_zavrsetka = COALESCE(datum_zavrsetka, date('now', 'localtime'))
WHERE id = ?`, model.StatusZavrseno, cena, id)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.OdbijPopravku: %w", err)
}
return nil
}
// AzurirajGaranciju postavlja ili briše datum garancije na servisnom nalogu.
// garancijaDo == nil → bez garancije.
func (r *ServisRepo) AzurirajGaranciju(ctx context.Context, id int64, garancijaDo *time.Time) error {
@@ -288,6 +303,39 @@ func (r *ServisRepo) AzurirajNapomenuKlijentu(ctx context.Context, id int64, tek
return nil
}
// AzurirajNalazDijagnostike postavlja tekst nalaza dijagnostike (dijagnoza kvara i predlog popravke) na nalogu
func (r *ServisRepo) AzurirajNalazDijagnostike(ctx context.Context, id int64, tekst string) error {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET nalaz_dijagnostike = ? WHERE id = ?", tekst, id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajNalazDijagnostike: %w", err)
}
return nil
}
// AzurirajUradjeno čuva tekst „šta je urađeno" na servisnom nalogu
func (r *ServisRepo) AzurirajUradjeno(ctx context.Context, id int64, tekst string) error {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET uradjeno = ? WHERE id = ?", tekst, id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajUradjeno: %w", err)
}
return nil
}
// AzurirajCenaDijagnostike postavlja cenu dijagnostike (taksa kad klijent ne prihvati popravku)
func (r *ServisRepo) AzurirajCenaDijagnostike(ctx context.Context, id int64, cena float64) error {
_, err := r.db.ExecContext(ctx,
"UPDATE servisni_nalozi SET cena_dijagnostike = ? WHERE id = ?", cena, id,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.AzurirajCenaDijagnostike: %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 {
@@ -369,16 +417,17 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error {
var klijentID, tehnicarID sql.NullInt64
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, javniToken sql.NullString
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, nalazDijagnostike, uradjeno, javniToken sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime
var garancijaDana sql.NullInt64
var popravkaOdbijena 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, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &javniToken,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken,
}
if klijentNaziv != nil {
@@ -403,6 +452,8 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
n.PinUredjaja = pinUredjaja.String
n.Pribor = pribor.String
n.NapomenaKlijentu = napomenaKlijentu.String
n.NalazDijagnostike = nalazDijagnostike.String
n.Uradjeno = uradjeno.String
if cenaOd.Valid {
v := cenaOd.Float64
n.CenaOd = &v
@@ -435,6 +486,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := int(garancijaDana.Int64)
n.GarancijaDana = &v
}
n.PopravkaOdbijena = popravkaOdbijena.Int64 != 0
n.JavniToken = javniToken.String
return nil
+16
View File
@@ -63,6 +63,7 @@ type PodaciPodesavanja struct {
BackupBrojKopija string
KalkulacijaMarza string
ServisGarancijaDana string
ServisCenaDijagnostike string
PredracunRokDana string
PredvidjenRokDana string
ServisUslovi string
@@ -403,6 +404,20 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
}
}
// podrazumevana cena dijagnostike (din, ≥ 0)
if v := strings.TrimSpace(r.FormValue("servis_cena_dijagnostike")); v != "" {
cena, err := strconv.ParseFloat(strings.ReplaceAll(v, ",", "."), 64)
if err != nil || cena < 0 {
middleware.SetFlash(w, r, h.DB, "greska", "Cena dijagnostike mora biti broj veći ili jednak 0.")
http.Redirect(w, r, sledeci, http.StatusSeeOther)
return
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_cena_dijagnostike", strconv.FormatFloat(cena, 'f', -1, 64)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return
}
}
// rok važenja predračuna u danima (190)
if v := strings.TrimSpace(r.FormValue("predracun_rok_dana")); v != "" {
n, err := strconv.Atoi(v)
@@ -831,6 +846,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
ServisGarancijaDana: vrednostIliDefault(podesavanja, "servis_garancija_dana", "60"),
ServisCenaDijagnostike: vrednostIliDefault(podesavanja, "servis_cena_dijagnostike", "0"),
PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"),
PredvidjenRokDana: vrednostIliDefault(podesavanja, "predvidjen_rok_dana", "15"),
ServisUslovi: vrednostIliDefault(podesavanja, "servis_uslovi", podrazumevaniUsloviServisa),
+174 -37
View File
@@ -50,23 +50,25 @@ type PodaciFormeNaloga struct {
// PodaciDetaljiNaloga su podaci za pregled jednog servisnog naloga
type PodaciDetaljiNaloga struct {
model.PodaciStranice
Nalog model.ServisniNalog
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
Artikli []model.ArtikalSaKategorijom
Usluge []model.Usluga
Sacuvano bool
UkupnoDelovi float64
UkupnoRadovi float64
UkupnoSve float64
PreostaloSve float64
ZakljucanStatus bool // onemogući promenu statusa dok ima potraživanih delova
SviStatusi []string
Nalog model.ServisniNalog
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
Artikli []model.ArtikalSaKategorijom
Usluge []model.Usluga
Sacuvano bool
UkupnoDelovi float64
UkupnoRadovi float64
UkupnoSve float64
PreostaloSve float64
ZakljucanStatus bool // onemogući promenu statusa dok ima potraživanih delova
CenaDijagnostikePredlog string // podrazumevana cena dijagnostike iz podešavanja (za prefill input-a)
KorisceneUsluge map[int64]bool // ID-evi usluga već dodatih na nalog — izostavljaju se iz dropdown-a
SviStatusi []string
}
// Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa
@@ -595,11 +597,23 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
for _, d := range delovi {
ukupnoDelovi += d.Ukupno()
}
// skup već dodatih usluga — izostavljaju se iz dropdown-a da se ista usluga ne doda dvaput
korisceneUsluge := make(map[int64]bool, len(radovi))
for _, rad := range radovi {
ukupnoRadovi += rad.Ukupno()
if rad.UslugaID != 0 {
korisceneUsluge[rad.UslugaID] = true
}
}
// ukupno za klijenta zavisi od ishoda dijagnostike:
// klijent odbio popravku → naplaćuje se samo cena dijagnostike
// inače (popravka u toku/gotova) → radovi + delovi, dijagnostika se ne gleda
var ukupnoSve float64
if nalog.PopravkaOdbijena {
ukupnoSve = nalog.CenaDijagnostike
} else {
ukupnoSve = ukupnoRadovi + ukupnoDelovi
}
// cena rada = zbir radova (usluga); ukupno za klijenta = radovi + delovi
ukupnoSve := ukupnoRadovi + ukupnoDelovi
avans := 0.0
if nalog.Avans != nil {
avans = *nalog.Avans
@@ -621,24 +635,26 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
ps.Stranica = "servis"
ps.NaslovStranice = "Detalji naloga"
podaci := PodaciDetaljiNaloga{
PodaciStranice: ps,
Nalog: *nalog,
KlijentNaziv: klijentNaziv,
TehnicarNaziv: tehnicarNaziv,
Tehnicari: tehnicari,
GarancijaDefault: garancijaDefault,
BezGarancije: bezGarancije,
ServisniDelovi: delovi,
ServisniRadovi: radovi,
Artikli: artikli,
Usluge: usluge,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
UkupnoDelovi: ukupnoDelovi,
UkupnoRadovi: ukupnoRadovi,
UkupnoSve: ukupnoSve,
PreostaloSve: preostaloSve,
ZakljucanStatus: zakljucanStatus,
SviStatusi: model.SviStatusi,
PodaciStranice: ps,
Nalog: *nalog,
KlijentNaziv: klijentNaziv,
TehnicarNaziv: tehnicarNaziv,
Tehnicari: tehnicari,
GarancijaDefault: garancijaDefault,
BezGarancije: bezGarancije,
ServisniDelovi: delovi,
ServisniRadovi: radovi,
Artikli: artikli,
Usluge: usluge,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
UkupnoDelovi: ukupnoDelovi,
UkupnoRadovi: ukupnoRadovi,
UkupnoSve: ukupnoSve,
PreostaloSve: preostaloSve,
ZakljucanStatus: zakljucanStatus,
CenaDijagnostikePredlog: podesavanja["servis_cena_dijagnostike"],
KorisceneUsluge: korisceneUsluge,
SviStatusi: model.SviStatusi,
}
h.renderujTemplate(w, "servis_detalji", podaci)
@@ -1806,6 +1822,127 @@ func (h *Handler) SacuvajNapomenuKlijentu(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// SacuvajNalazDijagnostike obrađuje POST /servis/{id}/nalaz-dijagnostike i čuva
// dijagnozu koju je serviser utvrdio (rezultat dijagnostike, hrani predračun)
func (h *Handler) SacuvajNalazDijagnostike(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
}
tekst := strings.TrimSpace(r.FormValue("nalaz_dijagnostike"))
if err := h.ServisRepo.AzurirajNalazDijagnostike(r.Context(), id, tekst); err != nil {
slog.Error("greška pri ažuriranju nalaza dijagnostike", "id", id, "error", err)
http.Error(w, "Greška pri ažuriranju nalaza", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// SacuvajUradjeno obrađuje POST /servis/{id}/uradjeno i čuva tekst „šta je urađeno"
// (serviser upisuje izvršene radove tokom popravke)
func (h *Handler) SacuvajUradjeno(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
}
tekst := strings.TrimSpace(r.FormValue("uradjeno"))
if err := h.ServisRepo.AzurirajUradjeno(r.Context(), id, tekst); err != nil {
slog.Error("greška pri ažuriranju urađenog", "id", id, "error", err)
http.Error(w, "Greška pri čuvanju", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// AzurirajCenaDijagnostike čuva cenu dijagnostike (taksa kad klijent ne prihvati popravku);
// prazno polje ili neispravna vrednost se tretira kao 0 (ne naplaćuje se)
func (h *Handler) AzurirajCenaDijagnostike(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
}
cena := 0.0
if s := strings.TrimSpace(r.FormValue("cena_dijagnostike")); s != "" {
v, e := strconv.ParseFloat(s, 64)
if e != nil || v < 0 {
http.Error(w, "Cena dijagnostike mora biti broj veći ili jednak nuli.", http.StatusBadRequest)
return
}
cena = v
}
if err := h.ServisRepo.AzurirajCenaDijagnostike(r.Context(), id, cena); err != nil {
slog.Error("greška pri ažuriranju cene dijagnostike", "id", id, "error", err)
http.Error(w, "Greška pri ažuriranju cene dijagnostike", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// OdbijPopravku obrađuje slučaj kad klijent posle dijagnostike ne prihvati popravku:
// nalog prelazi u „Završeno" i naplaćuje se samo cena dijagnostike. Cena se uzima iz
// forme, a ako je prazna koristi se podrazumevana iz podešavanja (Podešavanja → Servis).
func (h *Handler) OdbijPopravku(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
}
cenaStr := strings.TrimSpace(r.FormValue("cena_dijagnostike"))
if cenaStr == "" {
// nema unete cene na nalogu → uzmi podrazumevanu iz podešavanja
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
cenaStr = strings.TrimSpace(podesavanja["servis_cena_dijagnostike"])
}
cena := 0.0
if cenaStr != "" {
v, e := strconv.ParseFloat(strings.ReplaceAll(cenaStr, ",", "."), 64)
if e != nil || v < 0 {
http.Error(w, "Cena dijagnostike mora biti broj veći ili jednak nuli.", http.StatusBadRequest)
return
}
cena = v
}
if err := h.ServisRepo.OdbijPopravku(r.Context(), id, cena); err != nil {
slog.Error("greška pri odbijanju popravke", "id", id, "error", err)
http.Error(w, "Greška pri promeni statusa", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
type PodaciJavnogStatusa struct {
Nalog model.ServisniNalog
+12
View File
@@ -50,6 +50,10 @@ type ServisniNalog struct {
PinUredjaja string
Pribor string
NapomenaKlijentu string
NalazDijagnostike string // šta je serviser utvrdio pregledom (dijagnoza + predlog popravke)
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
JavniToken string
}
@@ -159,6 +163,14 @@ func (n ServisniNalog) AvansStr() string {
return fmt.Sprintf("%.2f", *n.Avans)
}
// CenaDijagnostikeStr vraća cenu dijagnostike za <input>, ili prazan string kad je 0
func (n ServisniNalog) CenaDijagnostikeStr() string {
if n.CenaDijagnostike == 0 {
return ""
}
return fmt.Sprintf("%.2f", n.CenaDijagnostike)
}
// PreostaloZaNaplatu vraća razliku konacna_cena avans, minimum 0.
// Vraća nil ako konačna cena nije uneta.
func (n ServisniNalog) PreostaloZaNaplatu() *float64 {
@@ -0,0 +1,3 @@
-- nalaz dijagnostike: šta je serviser utvrdio pregledom uređaja (dijagnoza kvara
-- i predlog popravke), odvojeno od „opis kvara" koji je ono što klijent prijavi
ALTER TABLE servisni_nalozi ADD COLUMN nalaz_dijagnostike TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,3 @@
-- cena dijagnostike: taksa koja se naplaćuje kada klijent ne prihvati popravku
-- posle dijagnostike; ulazi u ukupno za naplatu samo kada je veća od 0
ALTER TABLE servisni_nalozi ADD COLUMN cena_dijagnostike REAL NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
-- popravka_odbijena: označava nalog kod kog je klijent posle dijagnostike odbio popravku
-- (nalog ide u „Završeno", naplaćuje se samo dijagnostika, radovi i delovi se ne gledaju)
ALTER TABLE servisni_nalozi ADD COLUMN popravka_odbijena INTEGER NOT NULL DEFAULT 0;
+3
View File
@@ -0,0 +1,3 @@
-- uradjeno: serviser upisuje šta je stvarno urađeno na uređaju tokom popravke
-- (zaseban tekst od „nalaz_dijagnostike" koji opisuje utvrđeni kvar i predlog)
ALTER TABLE servisni_nalozi ADD COLUMN uradjeno TEXT NOT NULL DEFAULT '';
@@ -34,6 +34,19 @@
</div>
</div>
<div style="max-width:280px;">
<label for="servis_cena_dijagnostike" class="polje-labela">
Cena dijagnostike (din)
</label>
<input type="number" id="servis_cena_dijagnostike" name="servis_cena_dijagnostike"
min="0" step="0.01" value="{{.ServisCenaDijagnostike}}"
style="width:100%;">
<div class="pomocni-tekst" style="margin-top:6px;">
Podrazumevana taksa za pregled/dijagnostiku. Predlaže se na nalogu pri dijagnostici;
naplaćuje se samo ako klijent ne prihvati popravku. 0 znači bez naplate.
</div>
</div>
<div style="max-width:280px;">
<label for="predracun_rok_dana" class="polje-labela">
Rok važenja predračuna (dana)
+141 -19
View File
@@ -47,8 +47,8 @@
{{template "status-badge-detalji" .Nalog.Status}}
{{if eq .Nalog.Status "Primljeno"}}
<!-- serviser preuzima uređaj u rad → status prelazi u „U dijagnostici";
data-refresh osvežava samo zaglavlje (badge i ovo dugme se ponovo renderuju) -->
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#nalog-zaglavlje" style="margin:0;">
promena statusa menja kartice i dugmad, pa osvežavamo ceo sadržaj (#sadrzaj) -->
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin:0;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="status" value="U dijagnostici">
<button type="submit" class="btn-sekundarno" style="padding:3px 12px;font-size:12px;">
@@ -56,26 +56,72 @@
</button>
</form>
{{end}}
{{if eq .Nalog.Status "U dijagnostici"}}
<!-- ishod dijagnostike: klijent odobri popravku (→ U popravci) ili ne prihvata (→ Završeno, naplata pregleda) -->
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin:0;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="status" value="U popravci">
<button type="submit" class="btn-sekundarno" style="padding:3px 12px;font-size:12px;">
Odobri popravku
</button>
</form>
<form method="POST" action="/servis/{{.Nalog.ID}}/odbij-popravku" style="margin:0;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit" class="btn-sekundarno" style="padding:3px 12px;font-size:12px;"
data-potvrda="Klijent ne prihvata popravku? Nalog ide u „Završeno” i naplaćuje se samo dijagnostika.">
Klijent ne prihvata
</button>
</form>
{{end}}
{{if eq .Nalog.Status "U popravci"}}
<!-- popravka gotova → nalog prelazi u „Završeno" (na preuzimanje) -->
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin:0;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="status" value="Završeno">
<button type="submit" class="btn-sekundarno" style="padding:3px 12px;font-size:12px;">
Završi
</button>
</form>
{{end}}
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
{{/* Revers je potvrda o prijemu — primaran u „Primljeno"; kasnije ostaje samo diskretan reprint */}}
{{if eq .Nalog.Status "Primljeno"}}
<a href="/servis/{{.Nalog.ID}}/revers" target="_blank" class="btn-sekundarno">
Revers
</a>
{{else}}
<a href="/servis/{{.Nalog.ID}}/revers" target="_blank" style="font-size:12px;color:var(--tekst-sporedni);text-decoration:underline;align-self:center;">
Revers (reprint)
</a>
{{end}}
{{/* Predračun je ponuda klijentu sa procenom radova/delova — dostupan od prijema
(dijagnostika je opcioni korak; klijent često traži procenu odmah) */}}
<a href="/servis/{{.Nalog.ID}}/predracun" target="_blank" class="btn-sekundarno">
Predračun
</a>
{{/* Radni nalog je interni papir serviseru — šta treba uraditi; „U dijagnostici" i „Čeka delove" */}}
{{if or (eq .Nalog.Status "U dijagnostici") (eq .Nalog.Status "Čeka delove")}}
<a href="/servis/{{.Nalog.ID}}/radni-nalog" target="_blank" class="btn-sekundarno">
Radni nalog
</a>
{{end}}
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
Otpremnica
</a>
{{end}}
{{/* Nalepnica je primarna pri prijemu (lepi se na uređaj); kasnije ostaje samo diskretan reprint */}}
{{if eq .Nalog.Status "Primljeno"}}
<a href="/servis/{{.Nalog.ID}}/nalepnica" target="_blank" class="btn-sekundarno">
Nalepnica
</a>
{{else}}
<a href="/servis/{{.Nalog.ID}}/nalepnica" target="_blank" style="font-size:12px;color:var(--tekst-sporedni);text-decoration:underline;align-self:center;">
Nalepnica (reprint)
</a>
{{end}}
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
Izmeni nalog
</a>
@@ -244,6 +290,68 @@
</div>
</div>
<!-- nalaz dijagnostike: šta je serviser utvrdio (dijagnoza + predlog) — od „U dijagnostici" nadalje -->
{{if ne .Nalog.Status "Primljeno"}}
<div class="kartica detalji-kartica animiraj" id="nalaz-dijagnostike-kartica">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);flex-wrap:wrap;gap:10px;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Utvrđeni kvar</span>
</div>
{{/* kvar i cena dijagnostike se unose samo dok je nalog „U dijagnostici"; kasnije su readonly */}}
{{if and (index .Dozvole "servis.izmeni") (eq .Nalog.Status "U dijagnostici")}}
<!-- snima se kad textarea izgubi fokus (bez dugmeta); data-refresh osvežava samo ovu karticu -->
<form method="POST" action="/servis/{{.Nalog.ID}}/nalaz-dijagnostike" data-refresh="#nalaz-dijagnostike-kartica">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<textarea name="nalaz_dijagnostike" rows="3"
placeholder="Šta je serviser utvrdio pregledom — dijagnoza kvara i predlog popravke..."
style="width:100%;resize:vertical;"
data-poc="{{.Nalog.NalazDijagnostike}}"
onblur="if(this.value!==this.dataset.poc){this.dataset.poc=this.value;(this.form.requestSubmit?this.form.requestSubmit():this.form.submit());}">{{.Nalog.NalazDijagnostike}}</textarea>
</form>
<!-- cena dijagnostike: naplaćuje se ako klijent ne prihvati popravku; snima se na promenu -->
<form method="POST" action="/servis/{{.Nalog.ID}}/cena-dijagnostike" data-refresh="#nalaz-dijagnostike-kartica, #nalog-ukupno" style="margin-top:12px;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Cena dijagnostike (din)</label>
{{$cenaDij := .Nalog.CenaDijagnostikeStr}}{{if not $cenaDij}}{{$cenaDij = .CenaDijagnostikePredlog}}{{end}}
<input type="number" name="cena_dijagnostike" min="0" step="0.01" style="max-width:200px;"
placeholder="0"
value="{{$cenaDij}}"
data-poc="{{$cenaDij}}"
onchange="if(this.value!==this.dataset.poc){this.dataset.poc=this.value;(this.form.requestSubmit?this.form.requestSubmit():this.form.submit());}">
<div class="pomocni-tekst" style="margin-top:6px;">Predlog iz podešavanja; naplaćuje se samo ako klijent ne prihvati popravku.</div>
</form>
{{else}}
<div style="font-size:13px;color:var(--tekst-sporedni);line-height:1.6;white-space:pre-wrap;">
{{if .Nalog.NalazDijagnostike}}{{.Nalog.NalazDijagnostike}}{{else}}<span style="color:var(--tekst-sporedni);font-style:italic;">Nalaz još nije unet.</span>{{end}}
</div>
{{end}}
</div>
{{end}}
<!-- urađeno: šta je serviser stvarno uradio — od „U popravci" nadalje -->
{{if or (eq .Nalog.Status "U popravci") (eq .Nalog.Status "Čeka delove") (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
<div class="kartica detalji-kartica animiraj" id="uradjeno-kartica">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);flex-wrap:wrap;gap:10px;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Urađeno</span>
</div>
{{/* upisuje se dok traje popravka („U popravci" / „Čeka delove"); kasnije readonly */}}
{{if and (index .Dozvole "servis.izmeni") (or (eq .Nalog.Status "U popravci") (eq .Nalog.Status "Čeka delove"))}}
<!-- snima se kad textarea izgubi fokus (bez dugmeta); data-refresh osvežava samo ovu karticu -->
<form method="POST" action="/servis/{{.Nalog.ID}}/uradjeno" data-refresh="#uradjeno-kartica">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<textarea name="uradjeno" rows="3"
placeholder="Šta je serviser uradio na uređaju tokom popravke..."
style="width:100%;resize:vertical;"
data-poc="{{.Nalog.Uradjeno}}"
onblur="if(this.value!==this.dataset.poc){this.dataset.poc=this.value;(this.form.requestSubmit?this.form.requestSubmit():this.form.submit());}">{{.Nalog.Uradjeno}}</textarea>
</form>
{{else}}
<div style="font-size:13px;color:var(--tekst-sporedni);line-height:1.6;white-space:pre-wrap;">
{{if .Nalog.Uradjeno}}{{.Nalog.Uradjeno}}{{else}}<span style="color:var(--tekst-sporedni);font-style:italic;">Još nije uneto šta je urađeno.</span>{{end}}
</div>
{{end}}
</div>
{{end}}
<!-- napomena klijentu -->
<div class="kartica detalji-kartica animiraj" id="napomena-klijentu-kartica">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);flex-wrap:wrap;gap:10px;">
@@ -266,22 +374,24 @@
{{end}}
</div>
{{/* kad je popravka odbijena naplaćuje se samo dijagnostika — radovi i delovi se ne prikazuju */}}
{{if not .Nalog.PopravkaOdbijena}}
<!-- radovi (usluge) -->
<div class="kartica detalji-kartica animiraj">
<div class="kartica detalji-kartica animiraj" id="radovi-kartica">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);flex-wrap:wrap;gap:10px;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Radovi (usluge)</span>
</div>
<!-- forma za dodavanje rada iz cenovnika usluga -->
{{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/radovi" style="margin-bottom:16px;" data-refresh="#radovi-spisak">
<form method="POST" action="/servis/{{.Nalog.ID}}/radovi" style="margin-bottom:16px;" data-refresh="#radovi-kartica, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div class="detalji-forma-rad">
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Usluga</label>
<select name="usluga_id" id="rad-usluga" style="width:100%;" required>
<option value="">— odaberi uslugu —</option>
{{range .Usluge}}<option value="{{.ID}}" data-cena="{{.Cena}}">{{.Naziv}}</option>{{end}}
{{range .Usluge}}{{if not (index $.KorisceneUsluge .ID)}}<option value="{{.ID}}" data-cena="{{.Cena}}">{{.Naziv}}</option>{{end}}{{end}}
</select>
</div>
<div>
@@ -322,7 +432,7 @@
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
<td style="padding:9px 10px;text-align:center;">
{{if index $.Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/radovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#radovi-spisak">
<form method="POST" action="/servis/{{$.Nalog.ID}}/radovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#radovi-kartica, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit"
data-potvrda="Ukloniti rad '{{.Naziv}}'?"
@@ -353,7 +463,7 @@
<!-- forma za dodavanje dela -->
{{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/delovi" style="margin-bottom:16px;" data-refresh="#delovi-spisak, #nalog-zaglavlje">
<form method="POST" action="/servis/{{.Nalog.ID}}/delovi" style="margin-bottom:16px;" data-refresh="#delovi-spisak, #nalog-zaglavlje, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div style="margin-bottom:10px;">
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Kategorija</label>
@@ -414,7 +524,7 @@
</td>
<td style="padding:9px 10px;text-align:center;">
{{if and .ID (index $.Dozvole "servis.izmeni")}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#delovi-spisak, #nalog-zaglavlje">
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#delovi-spisak, #nalog-zaglavlje, #nalog-ukupno">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<button type="submit"
data-potvrda="Ukloniti deo '{{.ArtikalNaziv}}'?"
@@ -436,13 +546,21 @@
{{end}}
</div>
</div>
{{end}}
<!-- ukupno: zbir usluga (radova) i ugrađenih delova -->
<div class="kartica detalji-kartica animiraj">
<div id="nalog-ukupno" class="kartica detalji-kartica animiraj">
<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Ukupno</span>
</div>
<div class="kolona" style="gap:10px;">
{{if .Nalog.PopravkaOdbijena}}
{{/* klijent odbio popravku — naplaćuje se samo dijagnostika */}}
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:14px;color:var(--tekst-sporedni);">Dijagnostika</span>
<span style="font-size:14px;color:var(--tekst-glavni);">{{dinari .Nalog.CenaDijagnostike}} din</span>
</div>
{{else}}
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:14px;color:var(--tekst-sporedni);">Usluge (rad)</span>
<span style="font-size:14px;color:var(--tekst-glavni);">{{dinari .UkupnoRadovi}} din</span>
@@ -451,6 +569,7 @@
<span style="font-size:14px;color:var(--tekst-sporedni);">Ugrađeni delovi</span>
<span style="font-size:14px;color:var(--tekst-glavni);">{{dinari .UkupnoDelovi}} din</span>
</div>
{{end}}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;padding-top:12px;border-top:0.5px solid var(--ivica);">
<span style="font-size:16px;font-weight:600;color:var(--tekst-glavni);">Ukupno</span>
<span style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{dinari .UkupnoSve}} din</span>
@@ -459,6 +578,8 @@
</div>
<!-- zona za brisanje -->
{{/* Brisanje je moguće samo dok je „Primljeno" (greška pri unosu); čim uđe u proceduru naplaćuje se dijagnostika */}}
{{if eq .Nalog.Status "Primljeno"}}
<div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
@@ -475,21 +596,22 @@
</form>
</div>
</div>
{{end}}
</div>
<script>
// Radovi: pri izboru usluge iz <select>-a popuni predloženu cenu (data-cena).
(function () {
var sel = document.getElementById('rad-usluga');
// Delegacija na document — kartica radova se osvežava AJAX-om (data-refresh),
// pa listener vezan za sam <select> ne bi preživeo zamenu elementa.
document.addEventListener('change', function (e) {
if (!e.target || e.target.id !== 'rad-usluga') return;
var cena = document.getElementById('rad-cena');
if (!sel || !cena) return;
sel.addEventListener('change', function () {
var opt = sel.options[sel.selectedIndex];
var c = parseFloat(opt.getAttribute('data-cena'));
cena.value = (!isNaN(c) && c > 0) ? c.toFixed(2) : '0';
});
})();
if (!cena) return;
var opt = e.target.options[e.target.selectedIndex];
var c = parseFloat(opt.getAttribute('data-cena'));
cena.value = (!isNaN(c) && c > 0) ? c.toFixed(2) : '0';
});
// Delovi: kategorija filtrira <select> artikala; izborom artikla popunjavamo
// cenu i prikaz „U magacinu". Običan <select> ima native pretragu kucanjem
@@ -69,6 +69,10 @@
.strana { padding: 16px 20px; max-width: 100%; }
.dugme-stampa { display: none !important; }
@page { size: A4; margin: 12mm 14mm; }
/* red tabele se ne sme prelomiti preko granice stranice — prelom pada između stavki */
.tabela tr { break-inside: avoid; }
/* zaglavlje tabele se ponavlja na svakoj stranici kad tabela pređe na novu */
.tabela thead { display: table-header-group; }
}
.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; }
+27 -28
View File
@@ -11,7 +11,7 @@
.strana { max-width: 780px; margin: 0 auto; padding: 32px 40px; }
.zaglavlje { display: flex; align-items: stretch; margin-bottom: 18px; gap: 12px; }
.zg-okvir { flex: 1; padding: 12px 14px; border: 0.5px solid #ddd; border-radius: 6px; }
.zg-okvir { flex: 1; padding: 12px 14px; border: 1.5px solid #bbb; border-radius: 12px; }
.firma-naziv { font-size: 18px; font-weight: 700; }
.firma-info { font-size: 11px; color: #555; margin-top: 3px; line-height: 1.5; }
.dok-tip { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #555; margin-bottom: 4px; }
@@ -55,10 +55,13 @@
@page { size: A4; margin: 12mm 14mm; }
/* red tabele se ne sme prelomiti preko granice stranice */
.tabela tr { break-inside: avoid; }
/* „Ukupno radovi/delovi" se ne sme odvojiti od stavki iznad — bez preloma pre njega */
.tabela .ukupno-red { break-before: avoid; }
.tabela .ukupno-red td { break-before: avoid; }
/* zaglavlje tabele se ponavlja na svakoj stranici kad tabela pređe na novu */
.tabela thead { display: table-header-group; }
/* rekapitulacija, klauzula i potpisi ostaju u jednom komadu */
.rekap, .klauzula-blok, .potpisi { break-inside: avoid; }
/* od „Ukupno (procena)" do potpisa sve ostaje na jednoj strani — bez preloma */
.zavrsni-blok { break-inside: avoid; }
}
.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; }
@@ -83,7 +86,19 @@
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
<!-- SREDINA: klijent -->
<!-- SREDINA: datumi (levo) + QR (skroz desno) -->
<div class="zg-okvir" style="flex:1.3;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
<div>
<div class="dok-tip">Predračun (procena)</div>
<div class="dok-broj">{{.Nalog.BrojNaloga}}</div>
<div class="dok-datum">Datum: {{.DatumIzdavanja.Format "02.01.2006."}}</div>
<div class="dok-datum">Važi do: {{.VaziDo.Format "02.01.2006."}}</div>
</div>
{{if .QRKod}}<img src="data:image/png;base64,{{.QRKod}}" width="92" height="92" alt="QR {{.Nalog.BrojNaloga}}" style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">{{end}}
</div>
</div>
<!-- DESNO: klijent -->
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
@@ -100,19 +115,6 @@
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
<!-- DESNO: barkod na vrhu, ispod datumi (levo) + QR (skroz desno) -->
<div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
<div>
<div class="dok-tip">Predračun (procena)</div>
<div class="dok-broj">{{.Nalog.BrojNaloga}}</div>
<div class="dok-datum">Datum: {{.DatumIzdavanja.Format "02.01.2006."}}</div>
<div class="dok-datum">Važi do: {{.VaziDo.Format "02.01.2006."}}</div>
</div>
{{if .QRKod}}<img src="data:image/png;base64,{{.QRKod}}" width="64" height="64" alt="QR {{.Nalog.BrojNaloga}}" style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">{{end}}
</div>
</div>
</div>
<!-- uređaj i traženo -->
@@ -144,10 +146,6 @@
<td class="desno">{{dinari .Ukupno}} din</td>
</tr>
{{end}}
<tr class="ukupno-red">
<td colspan="3">Ukupno radovi</td>
<td class="desno">{{dinari .UkupnoRad}} din</td>
</tr>
</tbody>
</table>
</div>
@@ -175,10 +173,6 @@
<td class="desno">{{dinari .Ukupno}} din</td>
</tr>
{{end}}
<tr class="ukupno-red">
<td colspan="3">Ukupno delovi</td>
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
</tr>
</tbody>
</table>
</div>
@@ -190,10 +184,13 @@
</div>
{{end}}
<!-- rekapitulacija -->
<!-- završni blok: od rekapitulacije do potpisa — pri štampi ostaje u jednom komadu -->
<div class="zavrsni-blok">
<!-- rekapitulacija: ponavlja sume po sekciji (korisno kad tabele pređu na prethodnu stranu) -->
<div class="rekap">
{{if .Radovi}}<div class="rekap-red"><span>Radovi</span><span>{{dinari .UkupnoRad}} din</span></div>{{end}}
{{if .ServisniDelovi}}<div class="rekap-red"><span>Delovi i materijal</span><span>{{dinari .UkupnoDelovi}} din</span></div>{{end}}
{{if .Radovi}}<div class="rekap-red" style="font-weight:600;"><span>Ukupno radovi</span><span>{{dinari .UkupnoRad}} din</span></div>{{end}}
{{if .ServisniDelovi}}<div class="rekap-red" style="font-weight:600;"><span>Ukupno delovi</span><span>{{dinari .UkupnoDelovi}} din</span></div>{{end}}
<div class="rekap-ukupno">
<div class="rekap-ukupno-labela">Ukupno (procena):</div>
<div class="rekap-ukupno-iznos">{{dinari .UkupnoSve}} din</div>
@@ -217,6 +214,8 @@
</div>
</div>
</div><!-- /zavrsni-blok -->
</div>
<button class="dugme-stampa" onclick="window.print()">Štampaj</button>
@@ -59,6 +59,10 @@
.strana { padding: 16px 20px; max-width: 100%; }
.dugme-stampa { display: none !important; }
@page { size: A4; margin: 12mm 14mm; }
/* red tabele se ne sme prelomiti preko granice stranice — prelom pada između stavki */
.tabela tr { break-inside: avoid; }
/* zaglavlje tabele se ponavlja na svakoj stranici kad tabela pređe na novu */
.tabela thead { display: table-header-group; }
}
.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; }
+13 -13
View File
@@ -12,7 +12,7 @@
/* zaglavlje: tri okvira (firma · klijent · dokument) */
.zaglavlje { display: flex; align-items: stretch; margin-bottom: 20px; gap: 12px; }
.zg-okvir { flex: 1; padding: 12px 14px; border: 0.5px solid #ddd; border-radius: 6px; }
.zg-okvir { flex: 1; padding: 12px 14px; border: 1.5px solid #bbb; border-radius: 12px; }
.firma-naziv { font-size: 18px; font-weight: 700; }
.firma-info { font-size: 11px; color: #555; margin-top: 4px; line-height: 1.7; }
.dok-tip { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #555; margin-bottom: 4px; }
@@ -83,7 +83,17 @@
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
<!-- SREDINA: klijent -->
<!-- SREDINA: barkod na vrhu, ispod dokument i datumi (QR je u sekciji „Uređaj na servisu") -->
<div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
<div class="dok-tip">Revers — potvrda o prijemu</div>
<div class="dok-broj">{{.Nalog.BrojNaloga}}</div>
<div class="dok-datum">Datum prijema: {{.Nalog.DatumPrijema.Format "02.01.2006."}}</div>
{{if .Nalog.PredvidjenDatum}}
<div class="dok-datum">Predviđen rok: {{.Nalog.PredvidjenDatum.Format "02.01.2006."}}</div>
{{end}}
</div>
<!-- DESNO: klijent -->
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
@@ -100,16 +110,6 @@
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
<!-- DESNO: barkod na vrhu, ispod dokument i datumi (QR je u sekciji „Uređaj na servisu") -->
<div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
<div class="dok-tip">Revers — potvrda o prijemu</div>
<div class="dok-broj">{{.Nalog.BrojNaloga}}</div>
<div class="dok-datum">Datum prijema: {{.Nalog.DatumPrijema.Format "02.01.2006."}}</div>
{{if .Nalog.PredvidjenDatum}}
<div class="dok-datum">Predviđen rok: {{.Nalog.PredvidjenDatum.Format "02.01.2006."}}</div>
{{end}}
</div>
</div>
<!-- uređaj (QR skroz desno — klijent ga skenira da prati status) -->
@@ -154,7 +154,7 @@
</div>
{{if .QRKod}}
<div style="text-align:center;flex-shrink:0;">
<img src="data:image/png;base64,{{.QRKod}}" width="96" height="96" alt="QR {{.Nalog.BrojNaloga}}"
<img src="data:image/png;base64,{{.QRKod}}" width="128" height="128" alt="QR {{.Nalog.BrojNaloga}}"
style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;">
<div style="font-size:9px;color:#888;margin-top:3px;">Skenirajte za status</div>
</div>
@@ -55,8 +55,8 @@
/* red podataka */
.red { display: flex; justify-content: space-between; align-items: flex-start; padding: 10px 0; border-bottom: 0.5px solid #f0f0f0; gap: 12px; }
.red:last-child { border-bottom: none; }
.red-labela { font-size: 12px; color: #888; flex-shrink: 0; }
.red-vrednost { font-size: 13px; color: #111; font-weight: 500; text-align: right; }
.red-labela { font-size: 12px; color: #888; min-width: 0; overflow-wrap: anywhere; }
.red-vrednost { font-size: 13px; color: #111; font-weight: 500; text-align: right; flex-shrink: 0; white-space: nowrap; }
/* cena */
.cena-blok { background: #f9fafb; border-radius: 10px; padding: 14px 16px; margin-top: 4px; }
@@ -105,6 +105,8 @@
{{$s := .Nalog.Status}}
{{if eq $s "Primljeno"}}
<span class="status-bedz status-primljeno">Primljeno</span>
{{else if eq $s "U dijagnostici"}}
<span class="status-bedz status-dijagnostika">U dijagnostici</span>
{{else if eq $s "Čeka delove"}}
<span class="status-bedz status-ceka">Čeka delove</span>
{{else if eq $s "U popravci"}}
@@ -153,6 +155,13 @@
<span class="red-vrednost">{{garancijaTekst .Nalog.GarancijaDana}}</span>
</div>
{{if .Nalog.NalazDijagnostike}}
<div style="margin-top:12px;">
<div class="red-labela" style="margin-bottom:6px;">Utvrđeno na dijagnostici</div>
<div class="napomena-blok">{{.Nalog.NalazDijagnostike}}</div>
</div>
{{end}}
{{if .Nalog.NapomenaKlijentu}}
<div style="margin-top:12px;">
<div class="red-labela" style="margin-bottom:6px;">Napomena</div>