Servis predlog delovi: lager integracija, brisanje predloženih delova, odluka klijenta (migracija 079)
This commit is contained in:
@@ -375,6 +375,7 @@ func main() {
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/obrisi-komentar-klijenta", h.ObrisiKomentarKlijenta)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi", h.DodajDeloNalogu)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/predlozeni-delovi/{artikal_id}/obrisi", h.ObrisiPredlozeniDeoNaloga)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/radovi", h.DodajRadNalogu)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/radovi/{rad_id}/obrisi", h.ObrisiRadNaloga)
|
||||
r.Get("/izvestaji", h.Izvestaji)
|
||||
|
||||
@@ -178,6 +178,7 @@ type KlijentRepository interface {
|
||||
ListaFilter(ctx context.Context, filter KlijentFilter) ([]model.Klijent, error)
|
||||
PrebrojiPoFilteru(ctx context.Context, filter KlijentFilter) (int, error)
|
||||
DohvatiID(ctx context.Context, id int64) (*model.Klijent, error)
|
||||
Pronadji(ctx context.Context, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mesto string) (*model.Klijent, error)
|
||||
Kreiraj(ctx context.Context, k *model.Klijent) (int64, error)
|
||||
Izmeni(ctx context.Context, k *model.Klijent) error
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
@@ -201,6 +202,8 @@ type ServisRepository interface {
|
||||
AzurirajCenaDijagnostike(ctx context.Context, id int64, cena float64) error
|
||||
OdbijPopravku(ctx context.Context, id int64, cena float64) error
|
||||
AzurirajKomentarKlijenta(ctx context.Context, id int64, tekst string) error
|
||||
SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error
|
||||
ObrisiOdlukuKlijenta(ctx context.Context, id int64) error
|
||||
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
|
||||
SledeciBroj(ctx context.Context) (string, error)
|
||||
}
|
||||
@@ -236,6 +239,7 @@ type ServisniPotrazivaniDeloviRepository interface {
|
||||
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error)
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error
|
||||
ObrisiPredlozeneZaArtikal(ctx context.Context, nalogID, artikalID int64) error
|
||||
// ProveriIPocistiZaArtikal proverava potraživane redove za dati artikal nakon
|
||||
// povećanja stanja i briše/smanjuje redove koji se mogu pokriti (FIFO).
|
||||
// Vraća ID-eve naloga čiji je poslednji potraživani red obrisan.
|
||||
|
||||
@@ -3,6 +3,7 @@ package sqlite
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"ntech/internal/db"
|
||||
@@ -96,6 +97,115 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent,
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// Pronadji traži postojećeg klijenta po imenu i prezimenu (fizičko) ili nazivu firme (pravno).
|
||||
// Vraća nil, nil ako nije pronađen.
|
||||
// Pretraga ide od najočiglednijeg ka opštijem: JMBG/PIB → ime+prezime+mesto → telefon → email → samo ime+prezime.
|
||||
func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mesto string) (*model.Klijent, error) {
|
||||
var k model.Klijent
|
||||
var imeN, prezimeN, jmbgN, tipIdent, nazivFirmeN, pibN, telefonN, emailN, mestoN, napomena sql.NullString
|
||||
|
||||
skeniraj := func(row *sql.Row) error {
|
||||
return row.Scan(
|
||||
&k.ID, &k.Tip, &imeN, &prezimeN, &jmbgN, &tipIdent, &nazivFirmeN, &pibN, &telefonN, &emailN, &mestoN, &napomena, &k.DatumUnosa,
|
||||
)
|
||||
}
|
||||
|
||||
var row *sql.Row
|
||||
|
||||
if tip == "pravno" {
|
||||
// 1. PIB (najpreciznije za firmu)
|
||||
if jmbg != "" {
|
||||
row = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti WHERE tip = 'pravno' AND pib = ? LIMIT 1`, jmbg)
|
||||
if err := skeniraj(row); err == nil {
|
||||
goto popuni
|
||||
}
|
||||
}
|
||||
// 2. Naziv firme + mesto
|
||||
if nazivFirme != "" && mesto != "" {
|
||||
row = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? AND mesto = ? LIMIT 1`, nazivFirme, mesto)
|
||||
if err := skeniraj(row); err == nil {
|
||||
goto popuni
|
||||
}
|
||||
}
|
||||
// 3. Samo naziv firme
|
||||
row = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? LIMIT 1`, nazivFirme)
|
||||
if err := skeniraj(row); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Fizičko lice — više nivoa
|
||||
// 1. JMBG ili broj lične karte
|
||||
if jmbg != "" {
|
||||
row = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti WHERE tip != 'pravno' AND jmbg = ? LIMIT 1`, jmbg)
|
||||
if err := skeniraj(row); err == nil {
|
||||
goto popuni
|
||||
}
|
||||
}
|
||||
// 2. Ime + prezime + mesto
|
||||
if ime != "" && prezime != "" && mesto != "" {
|
||||
row = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND mesto = ? LIMIT 1`, ime, prezime, mesto)
|
||||
if err := skeniraj(row); err == nil {
|
||||
goto popuni
|
||||
}
|
||||
}
|
||||
// 3. Ime + prezime + telefon
|
||||
if ime != "" && prezime != "" && telefon != "" {
|
||||
row = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND telefon = ? LIMIT 1`, ime, prezime, telefon)
|
||||
if err := skeniraj(row); err == nil {
|
||||
goto popuni
|
||||
}
|
||||
}
|
||||
// 4. Ime + prezime + email
|
||||
if ime != "" && prezime != "" && email != "" {
|
||||
row = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND email = ? LIMIT 1`, ime, prezime, email)
|
||||
if err := skeniraj(row); err == nil {
|
||||
goto popuni
|
||||
}
|
||||
}
|
||||
// 5. Samo ime + prezime (poslednji fallback)
|
||||
row = r.db.QueryRowContext(ctx, `
|
||||
SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? LIMIT 1`, ime, prezime)
|
||||
if err := skeniraj(row); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
popuni:
|
||||
k.Ime = imeN.String
|
||||
k.Prezime = prezimeN.String
|
||||
k.JMBG = jmbgN.String
|
||||
k.TipIdentifikacije = tipIdent.String
|
||||
k.NazivFirme = nazivFirmeN.String
|
||||
k.PIB = pibN.String
|
||||
k.Telefon = telefonN.String
|
||||
k.Email = emailN.String
|
||||
k.Mesto = mestoN.String
|
||||
k.Napomena = napomena.String
|
||||
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// Kreiraj dodaje novog klijenta u bazu
|
||||
func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) {
|
||||
if k.Tip == "" {
|
||||
|
||||
@@ -58,10 +58,11 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
|
||||
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
|
||||
sn.opis_kvara, sn.trazene_nadogradnje, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
|
||||
sn.avans, sn.napomena, sn.garancija_do, sn.garancija_dana, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum,
|
||||
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta,
|
||||
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke,
|
||||
COALESCE(kp.naziv, '') AS klijent_naziv,
|
||||
(EXISTS(SELECT 1 FROM servis_radovi sr WHERE sr.nalog_id = sn.id AND sr.predlozeno = 1)
|
||||
OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1)) AS ima_predlog
|
||||
OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1)
|
||||
OR EXISTS(SELECT 1 FROM servisni_potrazivani_delovi spd WHERE spd.nalog_id = sn.id AND spd.predlozeno = 1)) AS ima_predlog
|
||||
FROM servisni_nalozi sn
|
||||
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
|
||||
WHERE 1=1`
|
||||
@@ -107,7 +108,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, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta
|
||||
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke
|
||||
FROM servisni_nalozi WHERE id = ?`, id)
|
||||
|
||||
var n model.ServisniNalog
|
||||
@@ -159,7 +160,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, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta
|
||||
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke
|
||||
FROM servisni_nalozi WHERE javni_token = ?`, token)
|
||||
|
||||
var n model.ServisniNalog
|
||||
@@ -349,6 +350,30 @@ func (r *ServisRepo) AzurirajKomentarKlijenta(ctx context.Context, id int64, tek
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObrisiOdlukuKlijenta poništava prethodnu odluku klijenta (npr. kad se doda novi predlog).
|
||||
func (r *ServisRepo) ObrisiOdlukuKlijenta(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"UPDATE servisni_nalozi SET odluka_klijenta = NULL, komentar_klijenta = NULL, datum_odluke = NULL WHERE id = ?", id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.ObrisiOdlukuKlijenta: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SacuvajOdlukuKlijenta beleži odluku klijenta (prihvaceno/odbijeno) i poruku.
|
||||
// Ako je prihvaćeno, poziva PrihvatiPredlozene za delove i radove.
|
||||
func (r *ServisRepo) SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"UPDATE servisni_nalozi SET odluka_klijenta = ?, komentar_klijenta = ?, datum_odluke = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
odluka, odgovor, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.SacuvajOdlukuKlijenta: %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 {
|
||||
@@ -430,9 +455,9 @@ 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, imaPredlog *bool) error {
|
||||
var klijentID, tehnicarID sql.NullInt64
|
||||
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, nalazDijagnostike, uradjeno, javniToken, komentarKlijenta sql.NullString
|
||||
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, nalazDijagnostike, uradjeno, javniToken, komentarKlijenta, odlukaKlijenta sql.NullString
|
||||
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
|
||||
var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime
|
||||
var garancijaDo, datumZavrsetka, predvidjenDatum, datumOdluke sql.NullTime
|
||||
var garancijaDana sql.NullInt64
|
||||
var popravkaOdbijena sql.NullInt64
|
||||
|
||||
@@ -440,7 +465,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
|
||||
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
|
||||
&n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
|
||||
&avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
|
||||
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta,
|
||||
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke,
|
||||
}
|
||||
|
||||
if klijentNaziv != nil {
|
||||
@@ -505,6 +530,11 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
|
||||
n.PopravkaOdbijena = popravkaOdbijena.Int64 != 0
|
||||
n.JavniToken = javniToken.String
|
||||
n.KomentarKlijenta = komentarKlijenta.String
|
||||
n.OdlukaKlijenta = odlukaKlijenta.String
|
||||
if datumOdluke.Valid {
|
||||
v := datumOdluke.Time
|
||||
n.DatumOdluke = &v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
@@ -23,7 +24,7 @@ func NoviServisniDeloviRepo(db *sql.DB) *ServisniDeloviRepo {
|
||||
func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error) {
|
||||
redovi, err := r.db.QueryContext(ctx, `
|
||||
SELECT sd.id, sd.nalog_id, sd.artikal_id, sd.kolicina, sd.cena_komada, sd.datum,
|
||||
sd.predlozeno, a.naziv
|
||||
sd.predlozeno, a.naziv, a.sifra
|
||||
FROM servisni_delovi sd
|
||||
JOIN artikli a ON a.id = sd.artikal_id
|
||||
WHERE sd.nalog_id = ?
|
||||
@@ -38,7 +39,7 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
|
||||
var d model.ServisniDeoSaArtiklom
|
||||
err := redovi.Scan(
|
||||
&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum,
|
||||
&d.Predlozeno, &d.ArtikalNaziv,
|
||||
&d.Predlozeno, &d.ArtikalNaziv, &d.ArtikalSifra,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: scan: %w", err)
|
||||
@@ -53,25 +54,52 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
|
||||
// stanju, a višak beleži u potraživane delove. Stanje se čita i koristi unutar iste
|
||||
// transakcije pa lager NIKAD ne ide u minus (nema TOCTOU između čitanja i upisa).
|
||||
// Vraća koliko je ugrađeno i koliko nedostaje.
|
||||
//
|
||||
// Predloženi delovi (predlozeno=true) ne skidaju sa lagera — sve ide u potraživane
|
||||
// do odluke klijenta. Tek PrihvatiPredlozene skida sa lagera.
|
||||
func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) {
|
||||
predInt := 0
|
||||
if predlozeno {
|
||||
predInt = 1
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
ugradjeno, nedostaje, err = ugradiIliPotrazujTx(ctx, tx, nalogID, artikalID, kolicina, cenaKomada, korisnikID, predlozeno)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: commit: %w", err)
|
||||
}
|
||||
return ugradjeno, nedostaje, nil
|
||||
}
|
||||
|
||||
// ugradiIliPotrazujTx je interna verzija koja radi unutar postojeće transakcije.
|
||||
func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) {
|
||||
// Predloženi delovi: ne skidaju sa lagera, svaki predlog je poseban red (ne merge)
|
||||
if predlozeno {
|
||||
slog.Info("PREDLOG_INSERT", "nalogID", nalogID, "artikalID", artikalID, "kolicina", kolicina)
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)",
|
||||
nalogID, artikalID, kolicina, cenaKomada,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("PREDLOG_INSERT_ERR", "err", err)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err)
|
||||
}
|
||||
slog.Info("PREDLOG_INSERT_OK")
|
||||
return 0, kolicina, nil
|
||||
}
|
||||
|
||||
// Ugrađeni delovi: standardna logika — skidamo sa lagera
|
||||
var stanjePre int
|
||||
if err = tx.QueryRowContext(ctx,
|
||||
"SELECT kolicina FROM artikli WHERE id = ?", artikalID,
|
||||
).Scan(&stanjePre); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: dohvati stanje: %w", err)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: dohvati stanje: %w", err)
|
||||
}
|
||||
|
||||
// ugrađujemo najviše onoliko koliko fizički imamo; ostatak ide u potraživane
|
||||
ugradjeno = kolicina
|
||||
if ugradjeno > stanjePre {
|
||||
ugradjeno = stanjePre
|
||||
@@ -81,75 +109,69 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art
|
||||
}
|
||||
nedostaje = kolicina - ugradjeno
|
||||
|
||||
// ugradi ono što imamo: merge u servisni_delovi, skini sa magacina, zabeleži promenu
|
||||
if ugradjeno > 0 {
|
||||
var postojeciID int64
|
||||
var postojeciKol int
|
||||
errRed := tx.QueryRowContext(ctx,
|
||||
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = ?",
|
||||
nalogID, artikalID, predInt,
|
||||
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 0",
|
||||
nalogID, artikalID,
|
||||
).Scan(&postojeciID, &postojeciKol)
|
||||
if errRed == nil {
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
|
||||
postojeciKol+ugradjeno, cenaKomada, postojeciID,
|
||||
); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: merge: %w", err)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: merge: %w", err)
|
||||
}
|
||||
} else if errors.Is(errRed, sql.ErrNoRows) {
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
nalogID, artikalID, ugradjeno, cenaKomada, predInt,
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
"INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)",
|
||||
nalogID, artikalID, ugradjeno, cenaKomada,
|
||||
); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: insert: %w", err)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: insert: %w", err)
|
||||
}
|
||||
} else {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: proveri: %w", errRed)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: proveri: %w", errRed)
|
||||
}
|
||||
|
||||
stanjePosle := stanjePre - ugradjeno
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
|
||||
); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: update stanje: %w", err)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: update stanje: %w", err)
|
||||
}
|
||||
if err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaIzlazServis,
|
||||
-ugradjeno, stanjePre, stanjePosle, nalogID, korisnikID, ""); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: magacin: %w", err)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: magacin: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// višak → potraživani delovi (ne skidamo sa lagera dok ne stigne)
|
||||
if nedostaje > 0 {
|
||||
var postojeciID int64
|
||||
var postojeciKol int
|
||||
errRed := tx.QueryRowContext(ctx,
|
||||
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = ?",
|
||||
nalogID, artikalID, predInt,
|
||||
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 0",
|
||||
nalogID, artikalID,
|
||||
).Scan(&postojeciID, &postojeciKol)
|
||||
if errRed == nil {
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
"UPDATE servisni_potrazivani_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
|
||||
postojeciKol+nedostaje, cenaKomada, postojeciID,
|
||||
); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani update: %w", err)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: potraživani update: %w", err)
|
||||
}
|
||||
} else if errors.Is(errRed, sql.ErrNoRows) {
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, ?)",
|
||||
nalogID, artikalID, nedostaje, cenaKomada, predInt,
|
||||
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)",
|
||||
nalogID, artikalID, nedostaje, cenaKomada,
|
||||
); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani insert: %w", err)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: potraživani insert: %w", err)
|
||||
}
|
||||
} else {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani proveri: %w", errRed)
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: potraživani proveri: %w", errRed)
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: commit: %w", err)
|
||||
}
|
||||
return ugradjeno, nedostaje, nil
|
||||
}
|
||||
|
||||
@@ -207,6 +229,7 @@ func (r *ServisniDeloviRepo) Obrisi(ctx context.Context, id int64, korisnikID *i
|
||||
|
||||
// PrihvatiPredlozene prebacuje sve predložene delove (i potraživane) naloga u
|
||||
// ugrađene (predlozeno → 0) — poziva se kad klijent prihvati predlog popravke.
|
||||
// Sada zaista skida sa lagera: predloženi delovi prethodno nisu dirali lager.
|
||||
func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int64) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -214,15 +237,51 @@ func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE servisni_delovi SET predlozeno = 0 WHERE nalog_id = ? AND predlozeno = 1", nalogID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: delovi: %w", err)
|
||||
// Dohvati sve predložene potraživane delove
|
||||
redovi, err := tx.QueryContext(ctx,
|
||||
"SELECT id, artikal_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 1",
|
||||
nalogID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: dohvati predlozene: %w", err)
|
||||
}
|
||||
defer redovi.Close()
|
||||
|
||||
type predlozeniStavka struct {
|
||||
id int64
|
||||
artikalID int64
|
||||
kolicina int
|
||||
cenaKomada float64
|
||||
}
|
||||
var stavke []predlozeniStavka
|
||||
for redovi.Next() {
|
||||
var s predlozeniStavka
|
||||
if err := redovi.Scan(&s.id, &s.artikalID, &s.kolicina, &s.cenaKomada); err != nil {
|
||||
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: scan: %w", err)
|
||||
}
|
||||
stavke = append(stavke, s)
|
||||
}
|
||||
|
||||
// Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može)
|
||||
for _, s := range stavke {
|
||||
_, _, err := ugradiIliPotrazujTx(ctx, tx, nalogID, s.artikalID, s.kolicina, s.cenaKomada, nil, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: ugradi: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Obriši predložene potraživane (prebačeni su u ugrađene/potraživane bez predlozeno)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE servisni_potrazivani_delovi SET predlozeno = 0 WHERE nalog_id = ? AND predlozeno = 1", nalogID,
|
||||
"DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: potraživani: %w", err)
|
||||
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: obrisi potraživane: %w", err)
|
||||
}
|
||||
|
||||
// Očisti i servisni_delovi sa predlozeno=1 (zaostali iz starije verzije)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"DELETE FROM servisni_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: obrisi delove: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
|
||||
@@ -54,6 +54,18 @@ func (r *ServisniPotrazivaniDeloviRepo) Obrisi(ctx context.Context, id int64) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObrisiPredlozeneZaArtikal briše samo predlozene redove (predlozeno=1) za dati artikal na nalogu
|
||||
func (r *ServisniPotrazivaniDeloviRepo) ObrisiPredlozeneZaArtikal(ctx context.Context, nalogID, artikalID int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 1",
|
||||
nalogID, artikalID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ObrisiPredlozeneZaArtikal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObrisiZaArtikal briše sve potraživane delove za dati artikal na datom nalogu
|
||||
func (r *ServisniPotrazivaniDeloviRepo) ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
@@ -96,7 +108,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
|
||||
}
|
||||
|
||||
redovi, err := tx.QueryContext(ctx,
|
||||
"SELECT id, nalog_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE artikal_id = ? ORDER BY datum",
|
||||
"SELECT id, nalog_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE artikal_id = ? AND predlozeno = 0 ORDER BY datum",
|
||||
artikalID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -165,7 +177,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
|
||||
for nalogID := range obrisaniNalozi {
|
||||
var preostalo int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id = ?", nalogID,
|
||||
"SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 0", nalogID,
|
||||
).Scan(&preostalo); err != nil {
|
||||
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: prebroji: %w", err)
|
||||
}
|
||||
@@ -187,7 +199,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
|
||||
var postojeciID int64
|
||||
var postojeciKol int
|
||||
err := tx.QueryRowContext(ctx,
|
||||
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ?",
|
||||
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 0",
|
||||
nalogID, artikalID,
|
||||
).Scan(&postojeciID, &postojeciKol)
|
||||
|
||||
|
||||
@@ -124,6 +124,50 @@ func TestPotrazivaniDelimicnoPokrivanje(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPotrazivaniPredlozeniSeNePokrivaju: predloženi delovi (predlozeno=1) ne smeju
|
||||
// biti auto-konvertovani u ugrađene niti smeti otključavanje naloga.
|
||||
func TestPotrazivaniPredlozeniSeNePokrivaju(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'GPU', 5)"); err != nil {
|
||||
t.Fatalf("insert artikal: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
"INSERT INTO servisni_nalozi (id, broj_naloga, uredjaj, opis_kvara, status) VALUES (1, 'SN-1', 'PC', 'kvar', 'U dijagnostici')",
|
||||
); err != nil {
|
||||
t.Fatalf("insert nalog: %v", err)
|
||||
}
|
||||
// predloženi deo serviser predlaže klijentu (predlozeno=1) — ne sme biti auto-ugradjen
|
||||
if _, err := db.ExecContext(ctx,
|
||||
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (1, 1, 3, 100, 1)",
|
||||
); err != nil {
|
||||
t.Fatalf("insert predlozeni: %v", err)
|
||||
}
|
||||
|
||||
potrRepo := NoviServisniPotrazivaniDeloviRepo(db)
|
||||
otkljucani, err := potrRepo.ProveriIPocistiZaArtikal(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ProveriIPocistiZaArtikal: %v", err)
|
||||
}
|
||||
// nalog se NE otključava — predlozeni redovi se ignorišu
|
||||
if len(otkljucani) != 0 {
|
||||
t.Fatalf("nalog ne sme da se otključa zbog predlozenih redova, dobijeno %v", otkljucani)
|
||||
}
|
||||
// predlozeni red mora ostati nepromenjen
|
||||
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_potrazivani_delovi WHERE nalog_id=1 AND predlozeno=1"); k != 3 {
|
||||
t.Fatalf("predlozeni deo mora ostati 3, dobijeno %d", k)
|
||||
}
|
||||
// magacin se ne sme dirljati
|
||||
if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 5 {
|
||||
t.Fatalf("stanje magacina mora ostati 5, dobijeno %d", stanje)
|
||||
}
|
||||
// u servisni_delovi ne sme biti ništa ubačeno
|
||||
if k := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_delovi WHERE nalog_id=1"); k != 0 {
|
||||
t.Fatalf("servisni_delovi mora biti prazan, dobijeno %d", k)
|
||||
}
|
||||
}
|
||||
|
||||
// skalarInt vraća jednu celobrojnu vrednost iz upita (za proveru stanja u testu)
|
||||
func skalarInt(t *testing.T, db *sql.DB, upit string) int {
|
||||
t.Helper()
|
||||
|
||||
@@ -21,8 +21,11 @@ func NoviServisniRadoviRepo(baza *sql.DB) *ServisniRadoviRepo {
|
||||
// DohvatiZaNalog vraća sve radove (usluge) jednog naloga, redom unosa
|
||||
func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) {
|
||||
redovi, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, nalog_id, COALESCE(usluga_id, 0), naziv, kolicina, cena_komada, datum, predlozeno
|
||||
FROM servis_radovi WHERE nalog_id = ? ORDER BY id`, nalogID)
|
||||
SELECT sr.id, sr.nalog_id, COALESCE(sr.usluga_id, 0), sr.naziv, sr.kolicina, sr.cena_komada, sr.datum, sr.predlozeno,
|
||||
COALESCE(u.sifra, '')
|
||||
FROM servis_radovi sr
|
||||
LEFT JOIN usluge u ON u.id = sr.usluga_id
|
||||
WHERE sr.nalog_id = ? ORDER BY sr.id`, nalogID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err)
|
||||
}
|
||||
@@ -31,7 +34,7 @@ func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
|
||||
var radovi []model.ServisniRad
|
||||
for redovi.Next() {
|
||||
var rad model.ServisniRad
|
||||
if err := redovi.Scan(&rad.ID, &rad.NalogID, &rad.UslugaID, &rad.Naziv, &rad.Kolicina, &rad.CenaKomada, &rad.Datum, &rad.Predlozeno); err != nil {
|
||||
if err := redovi.Scan(&rad.ID, &rad.NalogID, &rad.UslugaID, &rad.Naziv, &rad.Kolicina, &rad.CenaKomada, &rad.Datum, &rad.Predlozeno, &rad.Sifra); err != nil {
|
||||
return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err)
|
||||
}
|
||||
radovi = append(radovi, rad)
|
||||
|
||||
+148
-61
@@ -535,8 +535,11 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
for _, nalogID := range otkljucani {
|
||||
if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil {
|
||||
slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err)
|
||||
// reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd.
|
||||
if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove {
|
||||
if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil {
|
||||
slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -747,11 +750,13 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
|
||||
imeArtikla = art.Naziv
|
||||
}
|
||||
|
||||
// forma „Predloženi delovi" šalje predlozeno=1; forma „Ugrađeni delovi" ne šalje
|
||||
predlozeno := r.FormValue("predlozeno") == "1"
|
||||
|
||||
// predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje)
|
||||
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID)
|
||||
predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno
|
||||
slog.Info("DODAJ_DEO_IN", "status", nalog.Status, "predlozeno", predlozeno, "kol", kolicina)
|
||||
// atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane
|
||||
ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID, predlozeno)
|
||||
slog.Info("DODAJ_DEO_OUT", "ugradjeno", ugradjeno, "nedostaje", nedostaje, "err", err)
|
||||
if err != nil {
|
||||
slog.Error("greška pri dodavanju dela", "error", err)
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
|
||||
@@ -759,8 +764,9 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ako nešto nedostaje, prebaci nalog u „Čeka delove" i obavesti korisnika
|
||||
if nedostaje > 0 {
|
||||
// ako nešto nedostaje kod ugrađenog dela, prebaci nalog u „Čeka delove".
|
||||
// Za predložene delove ne menjamo status — to su samo predlozi, ne blokiraju rad.
|
||||
if nedostaje > 0 && !predlozeno {
|
||||
if e := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusCekaDelove); e != nil {
|
||||
slog.Error("greška pri prebacivanju naloga u Čeka delove", "error", e)
|
||||
}
|
||||
@@ -775,6 +781,16 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if nedostaje > 0 && predlozeno {
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh",
|
||||
"Predlog sačuvan — "+strconv.Itoa(kolicina)+" kom. „"+imeArtikla+
|
||||
"\" čeka odobrenje klijenta.")
|
||||
// novi predlog poništava prethodnu odluku klijenta
|
||||
if err := h.ServisRepo.ObrisiOdlukuKlijenta(r.Context(), nalogID); err != nil {
|
||||
slog.Error("greška pri brisanju odluke klijenta", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -795,26 +811,43 @@ func (h *Handler) deloviSaPotrazivanima(ctx context.Context, nalogID int64) ([]m
|
||||
artikalID int64
|
||||
predlozeno bool
|
||||
}
|
||||
potrMap := make(map[kljucDela]int)
|
||||
type potrVrednost struct {
|
||||
kolicina int
|
||||
cena float64
|
||||
}
|
||||
potrMap := make(map[kljucDela]potrVrednost)
|
||||
for _, p := range potrazivani {
|
||||
potrMap[kljucDela{p.ArtikalID, p.Predlozeno}] += p.Kolicina
|
||||
k := kljucDela{p.ArtikalID, p.Predlozeno}
|
||||
v := potrMap[k]
|
||||
v.kolicina += p.Kolicina
|
||||
if v.cena == 0 && p.CenaKomada > 0 {
|
||||
v.cena = p.CenaKomada
|
||||
}
|
||||
potrMap[k] = v
|
||||
}
|
||||
for i := range delovi {
|
||||
k := kljucDela{delovi[i].ArtikalID, delovi[i].Predlozeno}
|
||||
if kol, ok := potrMap[k]; ok {
|
||||
delovi[i].Potrazivano = kol
|
||||
if v, ok := potrMap[k]; ok {
|
||||
delovi[i].Potrazivano = v.kolicina
|
||||
delete(potrMap, k)
|
||||
}
|
||||
}
|
||||
for k, kol := range potrMap {
|
||||
for k, v := range potrMap {
|
||||
naziv := ""
|
||||
sifra := ""
|
||||
if art, e := h.Artikli.DohvatiID(ctx, k.artikalID); e == nil && art != nil {
|
||||
naziv = art.Naziv
|
||||
sifra = art.Sifra
|
||||
}
|
||||
delovi = append(delovi, model.ServisniDeoSaArtiklom{
|
||||
ArtikalNaziv: naziv,
|
||||
ServisniDeo: model.ServisniDeo{ArtikalID: k.artikalID, Predlozeno: k.predlozeno},
|
||||
Potrazivano: kol,
|
||||
ArtikalSifra: sifra,
|
||||
ServisniDeo: model.ServisniDeo{
|
||||
ArtikalID: k.artikalID,
|
||||
Kolicina: v.kolicina,
|
||||
CenaKomada: v.cena,
|
||||
Predlozeno: k.predlozeno,
|
||||
},
|
||||
})
|
||||
}
|
||||
return delovi, nil
|
||||
@@ -853,6 +886,28 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ObrisiPredlozeniDeoNaloga briše predlozene redove (predlozeno=1) za dati artikal na nalogu
|
||||
func (h *Handler) ObrisiPredlozeniDeoNaloga(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
|
||||
return
|
||||
}
|
||||
nalogID, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
artikalID, err := parseID(chi.URLParam(r, "artikal_id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.ServisniPotrazivaniDeloviRepo.ObrisiPredlozeneZaArtikal(r.Context(), nalogID, artikalID); err != nil {
|
||||
slog.Error("greška pri brisanju predloženog dela", "error", err)
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju predloženog dela.")
|
||||
}
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// DodajRadNalogu dodaje stavku rada (uslugu) na nalog; naziv i cena se snapshot-uju
|
||||
func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
|
||||
@@ -898,8 +953,9 @@ func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// forma „Predložene usluge" šalje predlozeno=1; forma „Radovi" ne šalje
|
||||
predlozeno := r.FormValue("predlozeno") == "1"
|
||||
// predlog ako status nije „Primljeno" (server odlučuje, ne forma)
|
||||
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID)
|
||||
predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno
|
||||
|
||||
if _, err := h.ServisniRadoviRepo.Dodaj(r.Context(), nalogID, uslugaID, usluga.Naziv, kolicina, cena, predlozeno); err != nil {
|
||||
slog.Error("greška pri dodavanju rada", "error", err)
|
||||
@@ -908,6 +964,13 @@ func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// novi predlog poništava prethodnu odluku klijenta
|
||||
if predlozeno {
|
||||
if err := h.ServisRepo.ObrisiOdlukuKlijenta(r.Context(), nalogID); err != nil {
|
||||
slog.Error("greška pri brisanju odluke klijenta", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -1052,10 +1115,26 @@ func (h *Handler) mozdaKreirajKlijenta(ctx context.Context, r *http.Request, nal
|
||||
tipIdent = "jmbg" // prazan unos — podrazumevani tip radi konzistentnosti baze
|
||||
}
|
||||
|
||||
prezime := strings.TrimSpace(r.FormValue("prezime"))
|
||||
telefon := strings.TrimSpace(r.FormValue("telefon"))
|
||||
mestoLok := strings.TrimSpace(r.FormValue("mesto"))
|
||||
|
||||
// prvo proveri da li klijent već postoji u bazi
|
||||
postojeci, err := h.KlijentiRepo.Pronadji(ctx, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mestoLok)
|
||||
if err != nil {
|
||||
slog.Error("greška pri pretrazi klijenta", "error", err)
|
||||
return "Došlo je do greške pri proveri klijenta. Pokušajte ponovo."
|
||||
}
|
||||
if postojeci != nil {
|
||||
nalog.KlijentID = &postojeci.ID
|
||||
return ""
|
||||
}
|
||||
|
||||
// klijent ne postoji — kreiraj novog
|
||||
klijent := model.Klijent{
|
||||
Tip: tip,
|
||||
Ime: ime,
|
||||
Prezime: strings.TrimSpace(r.FormValue("prezime")),
|
||||
Prezime: prezime,
|
||||
JMBG: jmbg,
|
||||
TipIdentifikacije: tipIdent,
|
||||
NazivFirme: nazivFirme,
|
||||
@@ -2008,24 +2087,24 @@ func (h *Handler) OdbijPopravku(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
|
||||
type PodaciJavnogStatusa struct {
|
||||
Nalog model.ServisniNalog
|
||||
NazivFirme string
|
||||
LogoPutanja string
|
||||
Podnazlov string
|
||||
Adresa string
|
||||
Telefon string
|
||||
PIB string
|
||||
MaticniBroj string
|
||||
Radovi []model.ServisniRad // ugrađeni/odobreni
|
||||
ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/odobreni
|
||||
UkupnoSve float64 // zbir ugrađenih (radovi + delovi)
|
||||
PredlozeniRadovi []model.ServisniRad
|
||||
PredlozeniDelovi []model.ServisniDeoSaArtiklom
|
||||
UkupnoPredlog float64 // zbir predloženih (radovi + delovi)
|
||||
ImaPredlog bool
|
||||
UkupnoSaPredlogom float64 // ukupno ugrađeno + predloženo
|
||||
Odgovoreno bool // klijent već odgovorio (nema predloga, nije „Primljeno")
|
||||
SviStatusi []string
|
||||
Nalog model.ServisniNalog
|
||||
NazivFirme string
|
||||
LogoPutanja string
|
||||
Podnazlov string
|
||||
Adresa string
|
||||
Telefon string
|
||||
PIB string
|
||||
MaticniBroj string
|
||||
Radovi []model.ServisniRad // ugrađeni/odobreni
|
||||
ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/odobreni
|
||||
UkupnoSve float64 // zbir ugrađenih (radovi + delovi)
|
||||
PredlozeniRadovi []model.ServisniRad
|
||||
PredlozeniDelovi []model.ServisniDeoSaArtiklom
|
||||
UkupnoPredlog float64 // zbir predloženih (radovi + delovi)
|
||||
ImaPredlog bool
|
||||
UkupnoSaPredlogom float64 // ukupno ugrađeno + predloženo
|
||||
Odgovoreno bool // klijent već odgovorio (nema predloga, nije „Primljeno")
|
||||
SviStatusi []string
|
||||
}
|
||||
|
||||
// ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda
|
||||
@@ -2077,24 +2156,24 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
|
||||
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
|
||||
Nalog: *nalog,
|
||||
NazivFirme: podesavanja["naziv_firme"],
|
||||
LogoPutanja: logoZaDokument(podesavanja),
|
||||
Podnazlov: podesavanja["podnazlov"],
|
||||
Adresa: podesavanja["adresa"],
|
||||
Telefon: podesavanja["telefon"],
|
||||
PIB: podesavanja["pib"],
|
||||
MaticniBroj: podesavanja["maticni_broj"],
|
||||
Radovi: ugrRadovi,
|
||||
ServisniDelovi: ugrDelovi,
|
||||
UkupnoSve: ukupnoSve,
|
||||
PredlozeniRadovi: predRadovi,
|
||||
PredlozeniDelovi: predDelovi,
|
||||
UkupnoPredlog: ukupnoPredlog,
|
||||
Nalog: *nalog,
|
||||
NazivFirme: podesavanja["naziv_firme"],
|
||||
LogoPutanja: logoZaDokument(podesavanja),
|
||||
Podnazlov: podesavanja["podnazlov"],
|
||||
Adresa: podesavanja["adresa"],
|
||||
Telefon: podesavanja["telefon"],
|
||||
PIB: podesavanja["pib"],
|
||||
MaticniBroj: podesavanja["maticni_broj"],
|
||||
Radovi: ugrRadovi,
|
||||
ServisniDelovi: ugrDelovi,
|
||||
UkupnoSve: ukupnoSve,
|
||||
PredlozeniRadovi: predRadovi,
|
||||
PredlozeniDelovi: predDelovi,
|
||||
UkupnoPredlog: ukupnoPredlog,
|
||||
ImaPredlog: len(predRadovi) > 0 || len(predDelovi) > 0,
|
||||
UkupnoSaPredlogom: ukupnoSve + ukupnoPredlog,
|
||||
Odgovoreno: !(len(predRadovi) > 0 || len(predDelovi) > 0) && nalog.Status != model.StatusPrimljeno,
|
||||
SviStatusi: model.SviStatusi,
|
||||
Odgovoreno: nalog.OdlukaKlijenta != "" || (!(len(predRadovi) > 0 || len(predDelovi) > 0) && nalog.Status != model.StatusPrimljeno),
|
||||
SviStatusi: model.SviStatusi,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2136,6 +2215,12 @@ func (h *Handler) ServisJavniPrihvati(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// odluka je već doneta — ne dozvoljavamo ponovno glasanje
|
||||
if nalog.OdlukaKlijenta != "" {
|
||||
http.Redirect(w, r, "/status/"+token, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.ServisniRadoviRepo.PrihvatiPredlozene(r.Context(), nalog.ID); err != nil {
|
||||
slog.Error("greška pri prihvatanju predloženih radova", "error", err, "nalog_id", nalog.ID)
|
||||
http.Error(w, "Greška pri prihvatanju predloga.", http.StatusInternalServerError)
|
||||
@@ -2147,11 +2232,9 @@ func (h *Handler) ServisJavniPrihvati(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// sačuvaj komentar klijenta
|
||||
if komentar := strings.TrimSpace(r.FormValue("komentar")); komentar != "" {
|
||||
if err := h.ServisRepo.AzurirajKomentarKlijenta(r.Context(), nalog.ID, komentar); err != nil {
|
||||
slog.Error("greška pri čuvanju komentara klijenta", "error", err, "nalog_id", nalog.ID)
|
||||
}
|
||||
komentar := strings.TrimSpace(r.FormValue("komentar"))
|
||||
if err := h.ServisRepo.SacuvajOdlukuKlijenta(r.Context(), nalog.ID, "prihvaceno", komentar); err != nil {
|
||||
slog.Error("greška pri čuvanju odluke klijenta", "error", err, "nalog_id", nalog.ID)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/status/"+token, http.StatusSeeOther)
|
||||
@@ -2199,6 +2282,12 @@ func (h *Handler) ServisJavniOdbij(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// odluka je već doneta — ne dozvoljavamo ponovno glasanje
|
||||
if nalog.OdlukaKlijenta != "" {
|
||||
http.Redirect(w, r, "/status/"+token, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// brišemo predložene stavke — klijent ih je odbio
|
||||
if err := h.ServisniRadoviRepo.ObrisiPredlozene(r.Context(), nalog.ID); err != nil {
|
||||
slog.Error("greška pri brisanju predloženih radova", "error", err, "nalog_id", nalog.ID)
|
||||
@@ -2207,11 +2296,9 @@ func (h *Handler) ServisJavniOdbij(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Error("greška pri brisanju predloženih delova", "error", err, "nalog_id", nalog.ID)
|
||||
}
|
||||
|
||||
// sačuvaj komentar klijenta
|
||||
if komentar := strings.TrimSpace(r.FormValue("komentar")); komentar != "" {
|
||||
if err := h.ServisRepo.AzurirajKomentarKlijenta(r.Context(), nalog.ID, komentar); err != nil {
|
||||
slog.Error("greška pri čuvanju komentara klijenta", "error", err, "nalog_id", nalog.ID)
|
||||
}
|
||||
komentar := strings.TrimSpace(r.FormValue("komentar"))
|
||||
if err := h.ServisRepo.SacuvajOdlukuKlijenta(r.Context(), nalog.ID, "odbijeno", komentar); err != nil {
|
||||
slog.Error("greška pri čuvanju odluke klijenta", "error", err, "nalog_id", nalog.ID)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/status/"+token, http.StatusSeeOther)
|
||||
|
||||
@@ -50,11 +50,13 @@ 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
|
||||
KomentarKlijenta string // poruka klijenta uz prihvatanje/odbijanje predloga
|
||||
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
|
||||
KomentarKlijenta string // poruka klijenta uz prihvatanje/odbijanje predloga
|
||||
OdlukaKlijenta string // '' / 'prihvaceno' / 'odbijeno'
|
||||
DatumOdluke *time.Time // kada je klijent doneo odluku
|
||||
JavniToken string
|
||||
}
|
||||
|
||||
@@ -78,6 +80,7 @@ func (d ServisniDeo) Ukupno() float64 {
|
||||
type ServisniDeoSaArtiklom struct {
|
||||
ServisniDeo
|
||||
ArtikalNaziv string
|
||||
ArtikalSifra string
|
||||
Potrazivano int // komada koji nedostaju (iz servisni_potrazivani_delovi), 0 ako nema
|
||||
}
|
||||
|
||||
@@ -102,6 +105,7 @@ type ServisniRad struct {
|
||||
NalogID int64
|
||||
UslugaID int64 // 0 ako je usluga u međuvremenu obrisana iz cenovnika
|
||||
Naziv string
|
||||
Sifra string
|
||||
Kolicina float64
|
||||
CenaKomada float64
|
||||
Datum string
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- odluka klijenta o predloženim delovima/uslugama (prihvatanje/odbijanje)
|
||||
ALTER TABLE servisni_nalozi ADD COLUMN odluka_klijenta TEXT DEFAULT '';
|
||||
ALTER TABLE servisni_nalozi ADD COLUMN datum_odluke DATETIME;
|
||||
@@ -9,6 +9,8 @@
|
||||
.detalji-kartica:nth-child(3) { animation-delay: 0.20s; }
|
||||
.detalji-kartica:nth-child(4) { animation-delay: 0.28s; }
|
||||
.detalji-kartica:nth-child(5) { animation-delay: 0.36s; }
|
||||
/* bez animacija na detaljima — smetaju pri dodavanju/brisanju stavki */
|
||||
#sadrzaj .kartica.animiraj { animation: none; }
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
@@ -158,8 +160,8 @@
|
||||
Nalepnica (reprint)
|
||||
</a>
|
||||
{{end}}
|
||||
{{/* nalog gotov/preuzet — nema više izmena */}}
|
||||
{{if and (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}}
|
||||
{{/* nalog u popravci ili gotov/preuzet — nema više izmena */}}
|
||||
{{if and (ne .Nalog.Status "U popravci") (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}}
|
||||
<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>
|
||||
@@ -441,6 +443,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div id="stavke-blok" style="display:flex;flex-direction:column;gap:16px;">
|
||||
{{/* kartice radova/delova se ne prikazuju kad je popravka odbijena (samo dijagnostika)
|
||||
ni kad je nalog „Završeno"/„Preuzeto" (posao gotov — sume ostaju u kartici Ukupno) */}}
|
||||
{{if and (not .Nalog.PopravkaOdbijena) (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}}
|
||||
@@ -452,7 +455,7 @@
|
||||
</div>
|
||||
|
||||
<!-- forma za dodavanje rada — u dijagnostici se automatski beleži kao predlog -->
|
||||
{{if index .Dozvole "servis.izmeni"}}
|
||||
{{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "U popravci")}}
|
||||
<form method="POST" action="/servis/{{.Nalog.ID}}/radovi" style="margin-bottom:16px;" data-refresh="#radovi-blok, #nalog-ukupno">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
{{if .ZakljucaniUgradjeni}}<input type="hidden" name="predlozeno" value="1">{{end}}
|
||||
@@ -487,6 +490,7 @@
|
||||
<thead>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Usluga</th>
|
||||
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:90px;">Šifra</th>
|
||||
<th style="padding:8px 10px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:80px;">Kol.</th>
|
||||
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Cena/kom</th>
|
||||
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Ukupno</th>
|
||||
@@ -497,6 +501,7 @@
|
||||
{{range .ServisniRadovi}}
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</td>
|
||||
<td style="padding:9px 10px;font-size:12px;color:var(--tekst-sporedni);font-family:monospace;">{{.Sifra}}</td>
|
||||
<td style="padding:9px 10px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{dinari .CenaKomada}} din</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
|
||||
@@ -517,11 +522,12 @@
|
||||
{{range .PredlozeniRadovi}}
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);background:rgba(245,158,11,0.08);">
|
||||
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.Naziv}} <span class="predlog-bedz" style="font-size:10px;padding:1px 7px;">Predlog</span></td>
|
||||
<td style="padding:9px 10px;font-size:12px;color:var(--tekst-sporedni);font-family:monospace;">{{.Sifra}}</td>
|
||||
<td style="padding:9px 10px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{dinari .CenaKomada}} din</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
|
||||
<td style="padding:9px 10px;text-align:center;">
|
||||
{{if index $.Dozvole "servis.izmeni"}}
|
||||
{{if and (index $.Dozvole "servis.izmeni") (ne $.Nalog.Status "U popravci")}}
|
||||
<form method="POST" action="/servis/{{$.Nalog.ID}}/radovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#radovi-blok, #nalog-ukupno">
|
||||
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
|
||||
<button type="submit"
|
||||
@@ -548,13 +554,18 @@
|
||||
|
||||
<!-- ugrađeni delovi -->
|
||||
<div id="delovi-blok">
|
||||
{{/* osvežava window._ntechArtikli i dropdown svaki put kad data-refresh osvežava #delovi-blok */}}
|
||||
<script>
|
||||
window._ntechArtikli = {{json .Artikli}};
|
||||
if (typeof window._ntechPopuniArtikle === 'function') window._ntechPopuniArtikle();
|
||||
</script>
|
||||
<div class="kartica detalji-kartica animiraj" id="delovi-kartica">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);flex-wrap:wrap;gap:10px;">
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Delovi</span>
|
||||
</div>
|
||||
|
||||
<!-- forma za dodavanje dela — u dijagnostici se automatski beleži kao predlog -->
|
||||
{{if index .Dozvole "servis.izmeni"}}
|
||||
{{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "U popravci")}}
|
||||
<form method="POST" action="/servis/{{.Nalog.ID}}/delovi" style="margin-bottom:16px;" data-refresh="#delovi-blok, #nalog-zaglavlje, #nalog-ukupno">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
{{if .ZakljucaniUgradjeni}}<input type="hidden" name="predlozeno" value="1">{{end}}
|
||||
@@ -596,6 +607,7 @@
|
||||
<thead>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Artikal</th>
|
||||
<th style="padding:8px 10px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:90px;">Šifra</th>
|
||||
<th style="padding:8px 10px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:80px;">Kol.</th>
|
||||
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Cena/kom</th>
|
||||
<th style="padding:8px 10px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:130px;">Ukupno</th>
|
||||
@@ -606,6 +618,7 @@
|
||||
{{range .ServisniDelovi}}
|
||||
<tr data-artikal-id="{{.ArtikalID}}" data-kolicina="{{.Kolicina}}" data-potrazivano="{{.Potrazivano}}" style="border-bottom:0.5px solid var(--ivica);{{if .Potrazivano}}background:rgba(220,38,38,0.06);{{end}}">
|
||||
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
|
||||
<td style="padding:9px 10px;font-size:12px;color:var(--tekst-sporedni);font-family:monospace;">{{.ArtikalSifra}}</td>
|
||||
<td style="padding:9px 10px;text-align:center;font-size:14px;{{if .Potrazivano}}color:#dc2626;font-weight:600;{{else}}color:var(--tekst-glavni);{{end}}">
|
||||
{{.Kolicina}}{{if .Potrazivano}}<span style="font-weight:400;"> + {{.Potrazivano}} na čekanju</span>{{end}}
|
||||
</td>
|
||||
@@ -630,10 +643,11 @@
|
||||
</tr>
|
||||
{{end}}
|
||||
{{range .PredlozeniDelovi}}
|
||||
<tr data-artikal-id="{{.ArtikalID}}" data-kolicina="{{.Kolicina}}" data-potrazivano="{{.Potrazivano}}" style="border-bottom:0.5px solid var(--ivica);background:rgba(245,158,11,0.08);{{if .Potrazivano}}background:rgba(220,38,38,0.06);{{end}}">
|
||||
<tr data-artikal-id="{{.ArtikalID}}" data-kolicina="{{.Kolicina}}" data-potrazivano="{{.Potrazivano}}" style="border-bottom:0.5px solid var(--ivica);background:rgba(245,158,11,0.08);">
|
||||
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}} <span class="predlog-bedz" style="font-size:10px;padding:1px 7px;">Predlog</span></td>
|
||||
<td style="padding:9px 10px;text-align:center;font-size:14px;{{if .Potrazivano}}color:#dc2626;font-weight:600;{{else}}color:var(--tekst-glavni);{{end}}">
|
||||
{{.Kolicina}}{{if .Potrazivano}}<span style="font-weight:400;"> + {{.Potrazivano}} na čekanju</span>{{end}}
|
||||
<td style="padding:9px 10px;font-size:12px;color:var(--tekst-sporedni);font-family:monospace;">{{.ArtikalSifra}}</td>
|
||||
<td style="padding:9px 10px;text-align:center;font-size:14px;color:var(--tekst-glavni);">
|
||||
{{if .Kolicina}}{{.Kolicina}}{{if .Potrazivano}} + {{.Potrazivano}}{{end}}{{else}}{{.Potrazivano}}{{end}}
|
||||
</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">
|
||||
{{if .Kolicina}}{{dinari .CenaKomada}} din{{else}}—{{end}}
|
||||
@@ -642,15 +656,22 @@
|
||||
{{if .Kolicina}}{{dinari .Ukupno}} din{{else}}—{{end}}
|
||||
</td>
|
||||
<td style="padding:9px 10px;text-align:center;">
|
||||
{{if and .ID (index $.Dozvole "servis.izmeni")}}
|
||||
{{if and (index $.Dozvole "servis.izmeni") (ne $.Nalog.Status "U popravci")}}
|
||||
{{if .ID}}
|
||||
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;" data-refresh="#delovi-blok, #nalog-zaglavlje, #nalog-ukupno">
|
||||
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
|
||||
<button type="submit"
|
||||
data-potvrda="Ukloniti predloženi deo '{{.ArtikalNaziv}}'?"
|
||||
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">
|
||||
×
|
||||
</button>
|
||||
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">×</button>
|
||||
</form>
|
||||
{{else if .ArtikalID}}
|
||||
<form method="POST" action="/servis/{{$.Nalog.ID}}/predlozeni-delovi/{{.ArtikalID}}/obrisi" style="display:inline;" data-refresh="#delovi-blok, #nalog-zaglavlje, #nalog-ukupno">
|
||||
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
|
||||
<button type="submit"
|
||||
data-potvrda="Ukloniti predloženi deo '{{.ArtikalNaziv}}'?"
|
||||
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">×</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -665,8 +686,32 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<script>(function(){
|
||||
var k=document.getElementById('deo-kategorija');
|
||||
var a=document.getElementById('deo-artikal');
|
||||
var u=document.getElementById('deo-umagacinu');
|
||||
var l=document.getElementById('deo-kol');
|
||||
var c=document.getElementById('deo-cena');
|
||||
var st=0;
|
||||
function osv(){if(!l||!a)return;var o=a.options[a.selectedIndex];var s=o?parseInt(o.getAttribute('data-stanje'),10):0;st=isNaN(s)?0:s;if(u)u.textContent=a.value?st:'—';if(l)l.classList.toggle('prekoraceno',a.value&&st===0)}
|
||||
function pop(){
|
||||
if(!a)return;
|
||||
var ka=k?k.value:'';
|
||||
a.innerHTML='<option value="">— odaberi artikal —</option>';
|
||||
var aa=window._ntechArtikli||[];
|
||||
aa.forEach(function(x){if(!ka||(x.KategorijaNaziv||'')===ka)a.innerHTML+='<option value="'+x.ID+'" data-cena="'+x.ProdajnaCena+'" data-stanje="'+(x.Kolicina||0)+'">'+x.Naziv+'</option>'})
|
||||
}
|
||||
if(k){var aa2=window._ntechArtikli||[];var kgs=[];aa2.forEach(function(x){var n=x.KategorijaNaziv||'';if(n&&kgs.indexOf(n)===-1)kgs.push(n)})
|
||||
k.innerHTML='<option value="">— sve kategorije —</option>'+kgs.map(function(x){return'<option value="'+x+'">'+x+'</option>'}).join('');k.onchange=pop}
|
||||
pop();
|
||||
if(a)a.onchange=function(){var o=a.options[a.selectedIndex];if(!o)return;var v=parseFloat(o.getAttribute('data-cena'));if(c)c.value=(!isNaN(v)&&v>0)?v.toFixed(2):'0';osv()}
|
||||
if(l)l.oninput=osv;
|
||||
var fd=document.querySelector('form[action$=\"/delovi\"]');
|
||||
if(fd)fd.addEventListener('reset',function(){if(u){u.textContent='—';u.classList.remove('prekoraceno')}st=0;if(l)l.classList.remove('prekoraceno')})
|
||||
})();</script>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>{{/* kraj stavke-blok */}}
|
||||
|
||||
|
||||
|
||||
@@ -739,7 +784,8 @@ document.addEventListener('change', function (e) {
|
||||
// cenu i prikaz „U magacinu". Običan <select> ima native pretragu kucanjem
|
||||
// i otvara se jednim klikom (za razliku od datalist-a).
|
||||
(function () {
|
||||
var ARTIKLI = {{json .Artikli}} || [];
|
||||
window._ntechArtikli = {{json .Artikli}} || [];
|
||||
var ARTIKLI = window._ntechArtikli;
|
||||
var kategSel = document.getElementById('deo-kategorija');
|
||||
var artikalSel = document.getElementById('deo-artikal');
|
||||
var cena = document.getElementById('deo-cena');
|
||||
@@ -748,21 +794,13 @@ document.addEventListener('change', function (e) {
|
||||
var stanjeIzabrano = 0;
|
||||
if (!artikalSel) return;
|
||||
|
||||
// boji „U magacinu" i polje količine crveno kada ukupno (postojeće u tabeli
|
||||
// + nova količina) prelazi stanje; čita direktno iz DOM-a tabele jer se ona
|
||||
// osvežava AJAX-om, pa JSON varijabla zastareva
|
||||
// boji „U magacinu" i polje količine crveno kada nova količina prelazi
|
||||
// trenutno stanje; stanjeIzabrano već odražava snižen lager (UgradiIliPotrazuj
|
||||
// skida sa stanja odmah pri dodavanju), pa nema potrebe sabirati ono što je
|
||||
// već u tabeli
|
||||
function osveziPrekoracenje() {
|
||||
var novaKol = parseInt(kol && kol.value, 10) || 0;
|
||||
var postojeciKol = 0;
|
||||
if (artikalSel.value) {
|
||||
var aid = artikalSel.value;
|
||||
var redovi = document.querySelectorAll('#delovi-spisak tr[data-artikal-id="' + aid + '"]');
|
||||
redovi.forEach(function (tr) {
|
||||
postojeciKol += (parseInt(tr.getAttribute('data-kolicina'), 10) || 0) +
|
||||
(parseInt(tr.getAttribute('data-potrazivano'), 10) || 0);
|
||||
});
|
||||
}
|
||||
var preko = (postojeciKol + novaKol) > stanjeIzabrano && artikalSel.value;
|
||||
var preko = novaKol > stanjeIzabrano && artikalSel.value;
|
||||
if (umagacinu) umagacinu.classList.toggle('prekoraceno', preko);
|
||||
if (kol) kol.classList.toggle('prekoraceno', preko);
|
||||
}
|
||||
@@ -779,6 +817,7 @@ document.addEventListener('change', function (e) {
|
||||
}
|
||||
|
||||
function popuniArtikle() {
|
||||
ARTIKLI = window._ntechArtikli || [];
|
||||
var izabrana = kategSel ? kategSel.value : '';
|
||||
artikalSel.innerHTML = '<option value="">— odaberi artikal —</option>' +
|
||||
ARTIKLI.filter(function (a) { return !izabrana || (a.KategorijaNaziv || '') === izabrana; })
|
||||
@@ -788,6 +827,7 @@ document.addEventListener('change', function (e) {
|
||||
}).join('');
|
||||
if (umagacinu) umagacinu.textContent = '—';
|
||||
}
|
||||
window._ntechPopuniArtikle = popuniArtikle;
|
||||
popuniArtikle();
|
||||
if (kategSel) kategSel.addEventListener('change', popuniArtikle);
|
||||
|
||||
@@ -804,17 +844,21 @@ document.addEventListener('change', function (e) {
|
||||
|
||||
// resetuj prikaz „U magacinu" nakon dodavanja dela — base.html poziva
|
||||
// f.reset() posle AJAX osvežavanja tabele, ali .reset() ne dira <div>
|
||||
var formaDelovi = document.querySelector('form[action$="/delovi"]');
|
||||
if (formaDelovi) {
|
||||
formaDelovi.addEventListener('reset', function () {
|
||||
if (umagacinu) {
|
||||
umagacinu.textContent = '—';
|
||||
umagacinu.classList.remove('prekoraceno');
|
||||
}
|
||||
stanjeIzabrano = 0;
|
||||
if (kol) kol.classList.remove('prekoraceno');
|
||||
});
|
||||
function zakaciResetDelovi() {
|
||||
var fd = document.querySelector('form[action$="/delovi"]');
|
||||
if (fd) {
|
||||
fd.addEventListener('reset', function () {
|
||||
if (umagacinu) {
|
||||
umagacinu.textContent = '—';
|
||||
umagacinu.classList.remove('prekoraceno');
|
||||
}
|
||||
stanjeIzabrano = 0;
|
||||
if (kol) kol.classList.remove('prekoraceno');
|
||||
});
|
||||
}
|
||||
}
|
||||
zakaciResetDelovi();
|
||||
|
||||
})();
|
||||
|
||||
// Popup polja (predviđen datum, garancija, serviser): nema dugmeta „Sačuvaj" —
|
||||
|
||||
@@ -95,6 +95,20 @@
|
||||
placeholder="npr. punjač, torbica, miš"
|
||||
style="width:100%;">
|
||||
</div>
|
||||
|
||||
<!-- serviser -->
|
||||
<div>
|
||||
<label class="polje-labela">Serviser</label>
|
||||
<select name="tehnicar_id" style="width:100%;">
|
||||
<option value="">— nedodeljen —</option>
|
||||
{{range .Tehnicari}}
|
||||
<option value="{{.ID}}"
|
||||
{{if eq .ID $.Nalog.TehnicarIDVrednost}}selected{{end}}>
|
||||
{{.KorisnickoIme}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,20 +251,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- serviser -->
|
||||
<div>
|
||||
<label class="polje-labela">Serviser</label>
|
||||
<select name="tehnicar_id" style="width:100%;">
|
||||
<option value="">— nedodeljen —</option>
|
||||
{{range .Tehnicari}}
|
||||
<option value="{{.ID}}"
|
||||
{{if eq .ID $.Nalog.TehnicarIDVrednost}}selected{{end}}>
|
||||
{{.KorisnickoIme}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -53,14 +53,19 @@
|
||||
.strana { padding: 16px 20px; max-width: 100%; }
|
||||
.dugme-stampa { display: none !important; }
|
||||
@page { size: A4; margin: 12mm 14mm; }
|
||||
.tabela tr { break-inside: avoid; }
|
||||
.tabela .ukupno-red { break-before: avoid; }
|
||||
.tabela .ukupno-red td { break-before: avoid; }
|
||||
.tabela thead { display: table-header-group; }
|
||||
.zavrsni-blok { break-inside: avoid; }
|
||||
/* 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 */
|
||||
/* zaglavlje tabele se ponavlja na svakoj stranici */
|
||||
.tabela thead { display: table-header-group; }
|
||||
/* od „Ukupno (procena)" do potpisa sve ostaje na jednoj strani — bez preloma */
|
||||
/* od „Ukupno (procena)" do potpisa sve ostaje na jednoj strani */
|
||||
.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); }
|
||||
|
||||
@@ -50,15 +50,21 @@
|
||||
.upis-veliki { min-height: 110px; }
|
||||
.upis-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
|
||||
|
||||
/* zavrsetak — vreme i potpis */
|
||||
.zavrsetak { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 28px; padding-top: 16px; border-top: 0.5px solid #ccc; }
|
||||
.linija { border-top: 1px solid #888; margin-top: 40px; padding-top: 6px; font-size: 11px; color: #555; }
|
||||
/* potpis */
|
||||
.zavrsetak { margin-top: 14px; padding-top: 10px; border-top: 0.5px solid #ccc; }
|
||||
.linija { border-top: 1px solid #888; margin-top: 24px; padding-top: 4px; font-size: 11px; color: #555; }
|
||||
|
||||
@media print {
|
||||
body { font-size: 12px; }
|
||||
.strana { padding: 16px 20px; max-width: 100%; }
|
||||
.strana { padding: 12px 16px; max-width: 100%; }
|
||||
.dugme-stampa { display: none !important; }
|
||||
@page { size: A4; margin: 12mm 14mm; }
|
||||
@page { size: A4; margin: 8mm 10mm; }
|
||||
.odeljak { margin-bottom: 10px; }
|
||||
.zaglavlje { margin-bottom: 12px; }
|
||||
.podaci-grid, .podaci-grid-2 { gap: 8px; }
|
||||
.upis-veliki { min-height: 80px; }
|
||||
.upis-okvir { min-height: 50px; }
|
||||
.zavrsetak { margin-top: 10px; padding-top: 8px; break-inside: avoid; }
|
||||
/* 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 */
|
||||
@@ -220,12 +226,8 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- vreme i potpis -->
|
||||
<!-- potpis -->
|
||||
<div class="zavrsetak">
|
||||
<div>
|
||||
<div class="polje-labela">Datum završetka / utrošeno vreme</div>
|
||||
<div class="linija"> </div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="polje-labela">Potpis servisera</div>
|
||||
<div class="linija"> </div>
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
|
||||
<!-- kartica: predlog popravke — serviser predložio posle dijagnostike -->
|
||||
{{if .ImaPredlog}}
|
||||
{{if not .Odgovoreno}}
|
||||
<div class="kartica predlog-kartica">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<span class="kartica-naslov" style="margin:0;">Predlog popravke</span>
|
||||
@@ -290,11 +291,21 @@
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .Odgovoreno}}
|
||||
<div class="kartica" style="border-color:#10b981;text-align:center;">
|
||||
<div class="kartica" style="{{if eq .Nalog.OdlukaKlijenta "prihvaceno"}}border-color:#10b981{{else if eq .Nalog.OdlukaKlijenta "odbijeno"}}border-color:#ef4444{{else}}border-color:#10b981{{end}};text-align:center;">
|
||||
{{if eq .Nalog.OdlukaKlijenta "prihvaceno"}}
|
||||
<div style="font-size:16px;font-weight:600;color:#10b981;">✅ Prihvatili ste predlog</div>
|
||||
{{else if eq .Nalog.OdlukaKlijenta "odbijeno"}}
|
||||
<div style="font-size:16px;font-weight:600;color:#ef4444;">✕ Niste prihvatili predlog</div>
|
||||
{{else}}
|
||||
<div style="font-size:16px;font-weight:600;color:#10b981;">Hvala na odgovoru!</div>
|
||||
<div style="font-size:12px;color:#94a3b8;margin-top:6px;">Serviser je obavešten o vašoj odluci.</div>
|
||||
{{end}}
|
||||
{{if .Nalog.KomentarKlijenta}}
|
||||
<div style="font-size:13px;color:#cbd5e1;margin-top:8px;background:#0f172a;padding:8px 12px;border-radius:8px;text-align:left;">{{.Nalog.KomentarKlijenta}}</div>
|
||||
{{end}}
|
||||
<div style="font-size:12px;color:#94a3b8;margin-top:8px;">Serviser je obavešten o vašoj odluci.</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user