Popravka sidebara: kolaps, podmeni i HTMX navigacija

This commit is contained in:
2026-06-08 19:29:17 +02:00
parent be74b22508
commit 19ecdf30a3
32 changed files with 1385 additions and 400 deletions
+2
View File
@@ -191,6 +191,8 @@ func main() {
r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga) r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
r.Post("/servis/obrisi/{id}", h.ObrisiNalog) r.Post("/servis/obrisi/{id}", h.ObrisiNalog)
r.Get("/servis/{id}", h.DetaljiNaloga) r.Get("/servis/{id}", h.DetaljiNaloga)
r.Post("/servis/{id}/delovi", h.DodajDeloNalogu)
r.Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga)
r.Get("/izvestaji", h.Izvestaji) r.Get("/izvestaji", h.Izvestaji)
r.Get("/prodaja", h.Prodaja) r.Get("/prodaja", h.Prodaja)
r.Get("/prodaja/nova", h.NovaProdaja) r.Get("/prodaja/nova", h.NovaProdaja)
+14 -1
View File
@@ -71,11 +71,24 @@ type ProdajaRepository interface {
Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error) Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error)
DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error) DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error)
DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error)
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje) (int64, error) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error)
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
SledeciBroj(ctx context.Context) (string, error) SledeciBroj(ctx context.Context) (string, error)
} }
// ServisniDeloviRepository definiše operacije nad ugrađenim delovima u servisu
type ServisniDeloviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error)
Dodaj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (int64, error)
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
}
// MagacinskePromeneRepository definiše operacije nad revizijskim tragom magacina
type MagacinskePromeneRepository interface {
Lista(ctx context.Context, artikalID *int64, limit int) ([]model.MagacinskaPromenaSaDetaljem, error)
}
// KorisniciRepository definiše operacije nad korisnicima // KorisniciRepository definiše operacije nad korisnicima
type KorisniciRepository interface { type KorisniciRepository interface {
Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error) Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error)
+9 -9
View File
@@ -25,7 +25,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
SELECT SELECT
a.id, a.kategorija_id, a.naziv, a.opis, a.id, a.kategorija_id, a.naziv, a.opis,
a.kolicina, a.kolicina_min, a.lokacija, a.kolicina, a.kolicina_min, a.lokacija,
a.prodajna_cena, a.napomena, a.datum_unosa, a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.napomena, a.datum_unosa,
COALESCE(k.naziv, '') as kategorija_naziv COALESCE(k.naziv, '') as kategorija_naziv
FROM artikli a FROM artikli a
LEFT JOIN kategorije k ON a.kategorija_id = k.id LEFT JOIN kategorije k ON a.kategorija_id = k.id
@@ -63,7 +63,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
err := redovi.Scan( err := redovi.Scan(
&a.ID, &kategorijaID, &a.Naziv, &a.Opis, &a.ID, &kategorijaID, &a.Naziv, &a.Opis,
&a.Kolicina, &a.KolicinMin, &a.Lokacija, &a.Kolicina, &a.KolicinMin, &a.Lokacija,
&a.ProdajnaCena, &a.Napomena, &a.DatumUnosa, &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa,
&a.KategorijaNaziv, &a.KategorijaNaziv,
) )
if err != nil { if err != nil {
@@ -89,11 +89,11 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, kategorija_id, naziv, opis, kolicina, kolicina_min, SELECT id, kategorija_id, naziv, opis, kolicina, kolicina_min,
lokacija, prodajna_cena, napomena, datum_unosa lokacija, nabavna_cena, prodajna_cena, pdv_stopa, napomena, datum_unosa
FROM artikli WHERE id = ?`, id).Scan( FROM artikli WHERE id = ?`, id).Scan(
&a.ID, &kategorijaID, &a.Naziv, &a.Opis, &a.ID, &kategorijaID, &a.Naziv, &a.Opis,
&a.Kolicina, &a.KolicinMin, &a.Lokacija, &a.Kolicina, &a.KolicinMin, &a.Lokacija,
&a.ProdajnaCena, &a.Napomena, &a.DatumUnosa, &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
@@ -111,10 +111,10 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
rezultat, err := r.db.ExecContext(ctx, ` rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO artikli INSERT INTO artikli
(kategorija_id, naziv, opis, kolicina, kolicina_min, lokacija, (kategorija_id, naziv, opis, kolicina, kolicina_min, lokacija,
prodajna_cena, napomena) nabavna_cena, prodajna_cena, pdv_stopa, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin, a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin,
a.Lokacija, a.ProdajnaCena, a.Napomena, a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err) return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err)
@@ -134,11 +134,11 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
UPDATE artikli SET UPDATE artikli SET
kategorija_id = ?, naziv = ?, opis = ?, kolicina = ?, kategorija_id = ?, naziv = ?, opis = ?, kolicina = ?,
kolicina_min = ?, lokacija = ?, kolicina_min = ?, lokacija = ?,
prodajna_cena = ?, napomena = ? nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, napomena = ?
WHERE id = ?`, WHERE id = ?`,
a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KategorijaID, a.Naziv, a.Opis, a.Kolicina,
a.KolicinMin, a.Lokacija, a.KolicinMin, a.Lokacija,
a.ProdajnaCena, a.Napomena, a.ID, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena, a.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err) return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err)
+24 -14
View File
@@ -21,7 +21,7 @@ func NoviKlijentRepo(db *sql.DB) *KlijentRepo {
// Lista vraća listu klijenata sa opcionom pretragom po imenu, prezimenu ili nazivu firme // Lista vraća listu klijenata sa opcionom pretragom po imenu, prezimenu ili nazivu firme
func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klijent, error) { func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klijent, error) {
upit := ` upit := `
SELECT id, ime, prezime, naziv_firme, pib, telefon, email, napomena, datum_unosa SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa
FROM klijenti FROM klijenti
WHERE 1=1` WHERE 1=1`
@@ -44,15 +44,16 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
var rezultat []model.Klijent var rezultat []model.Klijent
for redovi.Next() { for redovi.Next() {
var k model.Klijent var k model.Klijent
var ime, prezime, nazivFirme, pib, telefon, email, napomena sql.NullString var ime, prezime, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString
err := redovi.Scan( err := redovi.Scan(
&k.ID, &ime, &prezime, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa, &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: KlijentRepo.Lista: scan: %w", err) return nil, fmt.Errorf("ntech: KlijentRepo.Lista: scan: %w", err)
} }
k.Ime = ime.String k.Ime = ime.String
k.Prezime = prezime.String k.Prezime = prezime.String
k.JMBG = jmbg.String
k.NazivFirme = nazivFirme.String k.NazivFirme = nazivFirme.String
k.PIB = pib.String k.PIB = pib.String
k.Telefon = telefon.String k.Telefon = telefon.String
@@ -67,12 +68,12 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
// DohvatiID vraća jednog klijenta po ID-u // DohvatiID vraća jednog klijenta po ID-u
func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) { func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) {
var k model.Klijent var k model.Klijent
var ime, prezime, nazivFirme, pib, telefon, email, napomena sql.NullString var ime, prezime, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, ime, prezime, naziv_firme, pib, telefon, email, napomena, datum_unosa SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa
FROM klijenti WHERE id = ?`, id).Scan( FROM klijenti WHERE id = ?`, id).Scan(
&k.ID, &ime, &prezime, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa, &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: KlijentRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: KlijentRepo.DohvatiID: %w", err)
@@ -80,6 +81,7 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent,
k.Ime = ime.String k.Ime = ime.String
k.Prezime = prezime.String k.Prezime = prezime.String
k.JMBG = jmbg.String
k.NazivFirme = nazivFirme.String k.NazivFirme = nazivFirme.String
k.PIB = pib.String k.PIB = pib.String
k.Telefon = telefon.String k.Telefon = telefon.String
@@ -91,11 +93,15 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent,
// Kreiraj dodaje novog klijenta u bazu // Kreiraj dodaje novog klijenta u bazu
func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) { func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) {
if k.Tip == "" {
k.Tip = "fizicko"
}
rezultat, err := r.db.ExecContext(ctx, ` rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO klijenti (ime, prezime, naziv_firme, pib, telefon, email, napomena) INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullString(k.Ime), nullString(k.Prezime), nullString(k.NazivFirme), k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG),
nullString(k.PIB), nullString(k.Telefon), nullString(k.Email), nullString(k.Napomena), nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon),
nullString(k.Email), nullString(k.Napomena),
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: KlijentRepo.Kreiraj: %w", err) return 0, fmt.Errorf("ntech: KlijentRepo.Kreiraj: %w", err)
@@ -111,13 +117,17 @@ func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, err
// Izmeni ažurira postojećeg klijenta // Izmeni ažurira postojećeg klijenta
func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error { func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error {
if k.Tip == "" {
k.Tip = "fizicko"
}
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
UPDATE klijenti SET UPDATE klijenti SET
ime = ?, prezime = ?, naziv_firme = ?, pib = ?, telefon = ?, email = ?, napomena = ? tip = ?, ime = ?, prezime = ?, jmbg = ?, naziv_firme = ?,
pib = ?, telefon = ?, email = ?, napomena = ?
WHERE id = ?`, WHERE id = ?`,
nullString(k.Ime), nullString(k.Prezime), nullString(k.NazivFirme), k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG),
nullString(k.PIB), nullString(k.Telefon), nullString(k.Email), nullString(k.Napomena), nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon),
k.ID, nullString(k.Email), nullString(k.Napomena), k.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("ntech: KlijentRepo.Izmeni: %w", err) return fmt.Errorf("ntech: KlijentRepo.Izmeni: %w", err)
+102
View File
@@ -0,0 +1,102 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/model"
)
// MagacinskePromeneRepo je SQLite implementacija MagacinskePromeneRepository interfejsa
type MagacinskePromeneRepo struct {
db *sql.DB
}
// NoviMagacinskePromeneRepo kreira novi MagacinskePromeneRepo
func NoviMagacinskePromeneRepo(db *sql.DB) *MagacinskePromeneRepo {
return &MagacinskePromeneRepo{db: db}
}
// Lista vraća listu magacinskih promena, opcionalno filtrirano po artiklu
func (r *MagacinskePromeneRepo) Lista(ctx context.Context, artikalID *int64, limit int) ([]model.MagacinskaPromenaSaDetaljem, error) {
if limit <= 0 {
limit = 100
}
upit := `
SELECT mp.id, mp.artikal_id, a.naziv, mp.tip_promene, mp.referentni_id,
mp.promena_kolicine, mp.stanje_pre, mp.stanje_posle,
mp.korisnik_id, mp.napomena, mp.datum
FROM magacinske_promene mp
JOIN artikli a ON a.id = mp.artikal_id
WHERE 1=1`
args := []any{}
if artikalID != nil {
upit += " AND mp.artikal_id = ?"
args = append(args, *artikalID)
}
upit += " ORDER BY mp.datum DESC LIMIT ?"
args = append(args, limit)
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: MagacinskePromeneRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.MagacinskaPromenaSaDetaljem
for redovi.Next() {
var p model.MagacinskaPromenaSaDetaljem
var korisnikID sql.NullInt64
var napomena sql.NullString
err := redovi.Scan(
&p.ID, &p.ArtikalID, &p.ArtikalNaziv, &p.TipPromene, &p.ReferentniID,
&p.PromenaKolicine, &p.StanjePre, &p.StanjePosle,
&korisnikID, &napomena, &p.Datum,
)
if err != nil {
return nil, fmt.Errorf("ntech: MagacinskePromeneRepo.Lista: scan: %w", err)
}
if korisnikID.Valid {
v := korisnikID.Int64
p.KorisnikID = &v
}
p.Napomena = napomena.String
rezultat = append(rezultat, p)
}
return rezultat, nil
}
// zabeleziMagacinPromenu je interni helper koji upisuje jednu promenu stanja artikla.
// Poziva se iz Kreiraj/Storno unutar postojeće transakcije.
func zabeleziMagacinPromenu(
ctx context.Context,
tx *sql.Tx,
artikalID int64,
tipPromene string,
promenaKolicine, stanjePre, stanjePosle int,
referentniID int64,
korisnikID *int64,
napomena string,
) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO magacinske_promene
(artikal_id, tip_promene, promena_kolicine, stanje_pre, stanje_posle,
referentni_id, korisnik_id, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
artikalID, tipPromene, promenaKolicine, stanjePre, stanjePosle,
referentniID, nullInt64(korisnikID), nullString(napomena),
)
if err != nil {
return fmt.Errorf("ntech: zabeleziMagacinPromenu: %w", err)
}
return nil
}
+8
View File
@@ -4,8 +4,10 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"io/fs" "io/fs"
"log"
"path" "path"
"sort" "sort"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -82,8 +84,14 @@ func PokreniMigracije(db *sql.DB, fsys fs.FS) error {
// izvršavamo SQL // izvršavamo SQL
if _, err := db.Exec(string(sadrzaj)); err != nil { if _, err := db.Exec(string(sadrzaj)); err != nil {
// "duplicate column name" znači da kolona već postoji — željeno stanje je ispunjeno,
// pa nastavljamo i beležimo migraciju kao izvršenu
if strings.Contains(err.Error(), "duplicate column name") {
log.Printf("Upozorenje: migration %s: kolona već postoji, preskačemo", naziv)
} else {
return fmt.Errorf("ntech: PokreniMigracije: izvršavanje %s: %w", naziv, err) return fmt.Errorf("ntech: PokreniMigracije: izvršavanje %s: %w", naziv, err)
} }
}
// upisujemo u tabelu migracija da je izvršena // upisujemo u tabelu migracija da je izvršena
if _, err := db.Exec("INSERT INTO migracije (naziv) VALUES (?)", naziv); err != nil { if _, err := db.Exec("INSERT INTO migracije (naziv) VALUES (?)", naziv); err != nil {
+158 -44
View File
@@ -41,7 +41,8 @@ func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error) { func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error) {
upit := ` upit := `
SELECT SELECT
pn.id, pn.klijent_id, pn.broj_naloga, pn.napomena, pn.ukupno, pn.datum, pn.id, pn.klijent_id, pn.broj_naloga, pn.napomena, pn.ukupno,
pn.nacin_placanja, pn.stornirano, pn.datum,
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv
FROM prodajni_nalozi pn FROM prodajni_nalozi pn
LEFT JOIN klijenti k ON k.id = pn.klijent_id LEFT JOIN klijenti k ON k.id = pn.klijent_id
@@ -69,7 +70,8 @@ func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.Proda
var napomena sql.NullString var napomena sql.NullString
err := redovi.Scan( err := redovi.Scan(
&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno, &n.Datum, &n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno,
&n.NacinPlacanja, &n.Stornirano, &n.Datum,
&n.KlijentNaziv, &n.KlijentNaziv,
) )
if err != nil { if err != nil {
@@ -91,14 +93,18 @@ func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.Proda
// DohvatiID vraća jedan prodajni nalog po ID-u // DohvatiID vraća jedan prodajni nalog po ID-u
func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error) { func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error) {
red := r.db.QueryRowContext(ctx, ` red := r.db.QueryRowContext(ctx, `
SELECT id, klijent_id, broj_naloga, napomena, ukupno, datum SELECT id, klijent_id, broj_naloga, napomena, ukupno,
nacin_placanja, stornirano, razlog_storniranja, datum
FROM prodajni_nalozi WHERE id = ?`, id) FROM prodajni_nalozi WHERE id = ?`, id)
var n model.ProdajniNalog var n model.ProdajniNalog
var klijentID sql.NullInt64 var klijentID sql.NullInt64
var napomena sql.NullString var napomena, razlogStorniranja sql.NullString
err := red.Scan(&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno, &n.Datum) err := red.Scan(
&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno,
&n.NacinPlacanja, &n.Stornirano, &razlogStorniranja, &n.Datum,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiID: %w", err) return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiID: %w", err)
} }
@@ -108,15 +114,16 @@ func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniN
n.KlijentID = &v n.KlijentID = &v
} }
n.Napomena = napomena.String n.Napomena = napomena.String
n.RazlogStorniranja = razlogStorniranja.String
return &n, nil return &n, nil
} }
// DohvatiStavke vraća stavke prodaje sa nazivima artikala za dati nalog // DohvatiStavke vraća stavke prodaje sa nazivima artikala i PDV podacima za dati nalog
func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) { func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, ` redovi, err := r.db.QueryContext(ctx, `
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno, SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno,
a.naziv sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv
FROM stavke_prodaje sp FROM stavke_prodaje sp
JOIN artikli a ON a.id = sp.artikal_id JOIN artikli a ON a.id = sp.artikal_id
WHERE sp.nalog_id = ? WHERE sp.nalog_id = ?
@@ -131,7 +138,9 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model
var s model.StavkaProdajeSaArtiklom var s model.StavkaProdajeSaArtiklom
err := redovi.Scan( err := redovi.Scan(
&s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina, &s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
&s.CenaPoKomadu, &s.Ukupno, &s.ArtikalNaziv, &s.CenaPoKomadu, &s.Ukupno,
&s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv,
&s.ArtikalNaziv,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err) return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err)
@@ -143,44 +152,24 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model
} }
// Kreiraj upisuje novi prodajni nalog u bazu u okviru jedne transakcije. // Kreiraj upisuje novi prodajni nalog u bazu u okviru jedne transakcije.
// Za svaku stavku proverava stanje u magacinu i smanjuje ga. // Za svaku stavku proverava stanje u magacinu, smanjuje ga i beleži promenu.
// Ako bilo koji artikal nema dovoljno stanja, vraća ErrNedovoljnoKolicine. // Ako bilo koji artikal nema dovoljno stanja, vraća ErrNedovoljnoKolicine.
func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje) (int64, error) { func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error) {
if n.NacinPlacanja == "" {
n.NacinPlacanja = "gotovina"
}
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: begin tx: %w", err) return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: begin tx: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
// provera i smanjenje stanja za svaku stavku // insert zaglavlja naloga pre stavki da bismo imali nalogID za magacin
for _, s := range stavke {
var naziv string
var kolicinaNaStanju int
err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &kolicinaNaStanju)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
}
if kolicinaNaStanju < s.Kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = kolicina - ? WHERE id = ?",
s.Kolicina, s.ArtikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
}
}
// insert zaglavlja naloga
rezultat, err := tx.ExecContext(ctx, ` rezultat, err := tx.ExecContext(ctx, `
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, datum) INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.Datum, nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert nalog: %w", err) return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert nalog: %w", err)
@@ -191,17 +180,54 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: last insert id: %w", err) return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: last insert id: %w", err)
} }
// insert stavki // provera stanja, smanjenje i insert stavki
for _, s := range stavke { for _, s := range stavke {
var naziv string
var stanjePre int
err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &stanjePre)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
}
if stanjePre < s.Kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
stanjePosle := stanjePre - s.Kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
}
// PDV računamo iz cene ako nije eksplicitno postavljeno
cenaBezPdv := s.CenaBezPdv
pdvIznos := s.PdvIznos
if cenaBezPdv == 0 && s.PdvStopa > 0 {
cenaBezPdv = s.CenaPoKomadu / (1 + s.PdvStopa/100)
pdvIznos = s.CenaPoKomadu - cenaBezPdv
}
ukupnoStavke := float64(s.Kolicina) * s.CenaPoKomadu ukupnoStavke := float64(s.Kolicina) * s.CenaPoKomadu
_, err := tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
INSERT INTO stavke_prodaje (nalog_id, artikal_id, kolicina, cena_po_komadu, ukupno) INSERT INTO stavke_prodaje
VALUES (?, ?, ?, ?, ?)`, (nalog_id, artikal_id, kolicina, cena_po_komadu, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, ukupnoStavke, nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, ukupnoStavke,
s.PdvStopa, pdvIznos, cenaBezPdv,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err) return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
} }
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err)
}
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@@ -211,6 +237,83 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
return nalogID, nil return nalogID, nil
} }
// Storno označava prodajni nalog kao storniran i vraća sve artikle na stanje
func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: begin tx: %w", err)
}
defer tx.Rollback()
// proverava da li je već storniran
var stornirano bool
err = tx.QueryRowContext(ctx, "SELECT stornirano FROM prodajni_nalozi WHERE id = ?", id).Scan(&stornirano)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: provera: %w", err)
}
if stornirano {
return fmt.Errorf("ntech: ProdajaRepo.Storno: nalog je već storniran")
}
// vraćanje stanja u magacin
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stavke: %w", err)
}
type stavkaPovrat struct {
artikalID int64
kolicina int
}
var stavke []stavkaPovrat
for redovi.Next() {
var p stavkaPovrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ProdajaRepo.Storno: scan stavke: %w", err)
}
stavke = append(stavke, p)
}
redovi.Close()
for _, p := range stavke {
var stanjePre int
err := tx.QueryRowContext(ctx, "SELECT kolicina FROM artikli WHERE id = ?", p.artikalID).Scan(&stanjePre)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stanje: %w", err)
}
stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: vrati stanje: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, id, korisnikID, razlog)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: magacin: %w", err)
}
}
_, err = tx.ExecContext(ctx,
"UPDATE prodajni_nalozi SET stornirano = 1, razlog_storniranja = ? WHERE id = ?",
nullString(razlog), id,
)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: update nalog: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: commit: %w", err)
}
return nil
}
// Obrisi briše prodajni nalog i vraća količine artikala na stanje (u transakciji) // Obrisi briše prodajni nalog i vraća količine artikala na stanje (u transakciji)
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error { func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
@@ -219,14 +322,24 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
} }
defer tx.Rollback() defer tx.Rollback()
// vraćanje stanja u magacin // vraćanje stanja u magacin samo ako nalog nije storniran (storno je već vratio)
var stornirano bool
err = tx.QueryRowContext(ctx, "SELECT stornirano FROM prodajni_nalozi WHERE id = ?", id).Scan(&stornirano)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: provera: %w", err)
}
if !stornirano {
redovi, err := tx.QueryContext(ctx, redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) "SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id)
if err != nil { if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stavke: %w", err) return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stavke: %w", err)
} }
type povrat struct{ artikalID int64; kolicina int } type povrat struct {
artikalID int64
kolicina int
}
var stavke []povrat var stavke []povrat
for redovi.Next() { for redovi.Next() {
var p povrat var p povrat
@@ -247,6 +360,7 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err) return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err)
} }
} }
}
// CASCADE briše i stavke_prodaje // CASCADE briše i stavke_prodaje
_, err = tx.ExecContext(ctx, "DELETE FROM prodajni_nalozi WHERE id = ?", id) _, err = tx.ExecContext(ctx, "DELETE FROM prodajni_nalozi WHERE id = ?", id)
+28 -19
View File
@@ -40,9 +40,9 @@ func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]model.ServisniNalogSaKlijentom, error) { func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]model.ServisniNalogSaKlijentom, error) {
upit := ` upit := `
SELECT SELECT
sn.id, sn.klijent_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj, sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
sn.opis_kvara, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna, sn.opis_kvara, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
sn.avans, sn.napomena, sn.datum_prijema, sn.datum_zavrsetka, sn.avans, sn.napomena, sn.garancija_do, sn.datum_prijema, sn.datum_zavrsetka,
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv
FROM servisni_nalozi sn FROM servisni_nalozi sn
LEFT JOIN klijenti k ON k.id = sn.klijent_id LEFT JOIN klijenti k ON k.id = sn.klijent_id
@@ -86,9 +86,9 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNalog, error) { func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNalog, error) {
red := r.db.QueryRowContext(ctx, ` red := r.db.QueryRowContext(ctx, `
SELECT SELECT
id, klijent_id, broj_naloga, uredjaj, serijski_broj, id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, status, cena_od, cena_do, cena_konacna, opis_kvara, status, cena_od, cena_do, cena_konacna,
avans, napomena, datum_prijema, datum_zavrsetka avans, napomena, garancija_do, datum_prijema, datum_zavrsetka
FROM servisni_nalozi WHERE id = ?`, id) FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog var n model.ServisniNalog
@@ -104,13 +104,14 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) { func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
rezultat, err := r.db.ExecContext(ctx, ` rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO servisni_nalozi INSERT INTO servisni_nalozi
(klijent_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, (klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara,
status, cena_od, cena_do, cena_konacna, avans, napomena, datum_zavrsetka) status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), n.BrojNaloga, n.Uredjaj, nullString(n.SerijskiBroj), nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
n.OpisKvara, n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullString(n.SerijskiBroj), n.OpisKvara, n.Status,
nullFloat64(n.CenaKonacna), nullFloat64(n.Avans), nullString(n.Napomena), nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullTime(n.DatumZavrsetka), nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err) return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err)
@@ -128,13 +129,13 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error { func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
UPDATE servisni_nalozi SET UPDATE servisni_nalozi SET
klijent_id = ?, uredjaj = ?, serijski_broj = ?, opis_kvara = ?, klijent_id = ?, tehnicar_id = ?, uredjaj = ?, serijski_broj = ?, opis_kvara = ?,
status = ?, cena_od = ?, cena_do = ?, cena_konacna = ?, status = ?, cena_od = ?, cena_do = ?, cena_konacna = ?,
avans = ?, napomena = ?, datum_zavrsetka = ? avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?
WHERE id = ?`, WHERE id = ?`,
nullInt64(n.KlijentID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara, nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara,
n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna), n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.DatumZavrsetka), nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
n.ID, n.ID,
) )
if err != nil { if err != nil {
@@ -157,15 +158,15 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64) error {
// scanNalog čita redove iz upita u ServisniNalog struct — // scanNalog čita redove iz upita u ServisniNalog struct —
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a // klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error { func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error {
var klijentID sql.NullInt64 var klijentID, tehnicarID sql.NullInt64
var serijskiBroj, napomena sql.NullString var serijskiBroj, napomena sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64 var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var datumZavrsetka sql.NullTime var garancijaDo, datumZavrsetka sql.NullTime
args := []any{ args := []any{
&n.ID, &klijentID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj, &n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.Status, &cenaOd, &cenaDo, &cenaKonacna, &n.OpisKvara, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &n.DatumPrijema, &datumZavrsetka, &avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka,
} }
if klijentNaziv != nil { if klijentNaziv != nil {
@@ -180,6 +181,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := klijentID.Int64 v := klijentID.Int64
n.KlijentID = &v n.KlijentID = &v
} }
if tehnicarID.Valid {
v := tehnicarID.Int64
n.TehnicarID = &v
}
n.SerijskiBroj = serijskiBroj.String n.SerijskiBroj = serijskiBroj.String
n.Napomena = napomena.String n.Napomena = napomena.String
if cenaOd.Valid { if cenaOd.Valid {
@@ -198,6 +203,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := avans.Float64 v := avans.Float64
n.Avans = &v n.Avans = &v
} }
if garancijaDo.Valid {
v := garancijaDo.Time
n.GarancijaDo = &v
}
if datumZavrsetka.Valid { if datumZavrsetka.Valid {
v := datumZavrsetka.Time v := datumZavrsetka.Time
n.DatumZavrsetka = &v n.DatumZavrsetka = &v
+159
View File
@@ -0,0 +1,159 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/db"
"ntech/internal/model"
)
// ServisniDeloviRepo je SQLite implementacija ServisniDeloviRepository interfejsa
type ServisniDeloviRepo struct {
db *sql.DB
}
// NoviServisniDeloviRepo kreira novi ServisniDeloviRepo
func NoviServisniDeloviRepo(db *sql.DB) *ServisniDeloviRepo {
return &ServisniDeloviRepo{db: db}
}
// DohvatiZaNalog vraća sve ugrađene delove za dati servisni nalog
func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT sd.id, sd.nalog_id, sd.artikal_id, sd.kolicina, sd.cena_komada, sd.datum,
a.naziv
FROM servisni_delovi sd
JOIN artikli a ON a.id = sd.artikal_id
WHERE sd.nalog_id = ?
ORDER BY sd.datum`, nalogID)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: %w", err)
}
defer redovi.Close()
var rezultat []model.ServisniDeoSaArtiklom
for redovi.Next() {
var d model.ServisniDeoSaArtiklom
err := redovi.Scan(
&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum,
&d.ArtikalNaziv,
)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: scan: %w", err)
}
rezultat = append(rezultat, d)
}
return rezultat, nil
}
// Dodaj dodaje jedan artikal u servisni nalog, smanjuje stanje u magacinu i beleži promenu
func (r *ServisniDeloviRepo) Dodaj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (int64, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: begin tx: %w", err)
}
defer tx.Rollback()
var naziv string
var stanjePre int
err = tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", artikalID,
).Scan(&naziv, &stanjePre)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: dohvati artikal: %w", err)
}
if stanjePre < kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
stanjePosle := stanjePre - kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: update stanje: %w", err)
}
rezultat, err := tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada)
VALUES (?, ?, ?, ?)`,
nalogID, artikalID, kolicina, cenaKomada,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: insert: %w", err)
}
deoID, err := rezultat.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: last insert id: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaIzlazServis,
-kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: magacin: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: commit: %w", err)
}
return deoID, nil
}
// Obrisi uklanja servisni deo i vraća količinu na stanje u magacinu
func (r *ServisniDeloviRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: begin tx: %w", err)
}
defer tx.Rollback()
var artikalID int64
var nalogID int64
var kolicina int
err = tx.QueryRowContext(ctx,
"SELECT artikal_id, nalog_id, kolicina FROM servisni_delovi WHERE id = ?", id,
).Scan(&artikalID, &nalogID, &kolicina)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: dohvati deo: %w", err)
}
var stanjePre int
err = tx.QueryRowContext(ctx,
"SELECT kolicina FROM artikli WHERE id = ?", artikalID,
).Scan(&stanjePre)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: dohvati stanje: %w", err)
}
stanjePosle := stanjePre + kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: vrati stanje: %w", err)
}
_, err = tx.ExecContext(ctx, "DELETE FROM servisni_delovi WHERE id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: delete: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaPovracaj,
kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "uklonjen servisni deo")
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: magacin: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: commit: %w", err)
}
return nil
}
+6
View File
@@ -22,6 +22,8 @@ type Handler struct {
NabavkeRepo db.NabavkaRepository NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository SesijeRepo db.SesijeRepository
@@ -44,6 +46,8 @@ func Novi(baza *sql.DB) *Handler {
NabavkeRepo: sqlite.NoviNabavkaRepo(baza), NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza), KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza), ServisRepo: sqlite.NoviServisRepo(baza),
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza), ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza), KorisniciRepo: sqlite.NoviKorisniciRepo(baza),
SesijeRepo: sqlite.NoviSesijeRepo(baza), SesijeRepo: sqlite.NoviSesijeRepo(baza),
@@ -63,6 +67,8 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB) h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB) h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB)
h.ServisRepo = sqlite.NoviServisRepo(novaDB) h.ServisRepo = sqlite.NoviServisRepo(novaDB)
h.ServisniDeloviRepo = sqlite.NoviServisniDeloviRepo(novaDB)
h.MagacinskePromeneRepo = sqlite.NoviMagacinskePromeneRepo(novaDB)
h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB) h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB)
h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB) h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB)
h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB) h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
+13 -3
View File
@@ -227,21 +227,31 @@ func (h *Handler) ObrisiKlijenta(w http.ResponseWriter, r *http.Request) {
// parseFormuKlijenta čita polja iz HTTP forme, validira ih i vraća model i eventualnu grešku // parseFormuKlijenta čita polja iz HTTP forme, validira ih i vraća model i eventualnu grešku
func parseFormuKlijenta(r *http.Request) (model.Klijent, string) { func parseFormuKlijenta(r *http.Request) (model.Klijent, string) {
tip := r.FormValue("tip")
if tip != "fizicko" && tip != "pravno" {
tip = "fizicko"
}
ime := strings.TrimSpace(r.FormValue("ime")) ime := strings.TrimSpace(r.FormValue("ime"))
nazivFirme := strings.TrimSpace(r.FormValue("naziv_firme")) nazivFirme := strings.TrimSpace(r.FormValue("naziv_firme"))
if ime == "" && nazivFirme == "" { if tip == "fizicko" && ime == "" {
return model.Klijent{}, "Mora biti uneto ime i prezime ili naziv firme." return model.Klijent{Tip: tip}, "Za fizičko lice obavezno je uneti ime."
}
if tip == "pravno" && nazivFirme == "" {
return model.Klijent{Tip: tip}, "Za pravno lice obavezan je naziv firme."
} }
email := strings.TrimSpace(r.FormValue("email")) email := strings.TrimSpace(r.FormValue("email"))
if email != "" && !strings.Contains(email, "@") { if email != "" && !strings.Contains(email, "@") {
return model.Klijent{}, "Adresa e-pošte nije ispravna." return model.Klijent{Tip: tip}, "Adresa e-pošte nije ispravna."
} }
return model.Klijent{ return model.Klijent{
Tip: tip,
Ime: ime, Ime: ime,
Prezime: strings.TrimSpace(r.FormValue("prezime")), Prezime: strings.TrimSpace(r.FormValue("prezime")),
JMBG: strings.TrimSpace(r.FormValue("jmbg")),
NazivFirme: nazivFirme, NazivFirme: nazivFirme,
PIB: strings.TrimSpace(r.FormValue("pib")), PIB: strings.TrimSpace(r.FormValue("pib")),
Telefon: strings.TrimSpace(r.FormValue("telefon")), Telefon: strings.TrimSpace(r.FormValue("telefon")),
+3
View File
@@ -606,6 +606,7 @@ func (h *Handler) PodesavanjaOpste(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return return
} }
podaci.Stranica = "podesavanja-opste"
h.renderujTemplate(w, "podesavanja_opste", podaci) h.renderujTemplate(w, "podesavanja_opste", podaci)
} }
@@ -621,6 +622,7 @@ func (h *Handler) PodesavanjaIzgled(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return return
} }
podaci.Stranica = "podesavanja-izgled"
h.renderujTemplate(w, "podesavanja_izgled", podaci) h.renderujTemplate(w, "podesavanja_izgled", podaci)
} }
@@ -636,6 +638,7 @@ func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return return
} }
podaci.Stranica = "podesavanja-sistem"
h.renderujTemplate(w, "podesavanja_sistem", podaci) h.renderujTemplate(w, "podesavanja_sistem", podaci)
} }
+26 -7
View File
@@ -57,17 +57,18 @@ type PodaciStampeProdaje struct {
PIB string PIB string
} }
// artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom i stanjem // artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom, PDV stopom i stanjem
func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS { func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS {
type stavka struct { type stavka struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Naziv string `json:"naziv"` Naziv string `json:"naziv"`
Cena float64 `json:"cena"` Cena float64 `json:"cena"`
PdvStopa float64 `json:"pdv_stopa"`
Kolicina int `json:"kolicina"` Kolicina int `json:"kolicina"`
} }
lista := make([]stavka, 0, len(artikli)) lista := make([]stavka, 0, len(artikli))
for _, a := range artikli { for _, a := range artikli {
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, Cena: a.ProdajnaCena, Kolicina: a.Kolicina}) lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, Cena: a.ProdajnaCena, PdvStopa: a.PdvStopa, Kolicina: a.Kolicina})
} }
b, _ := json.Marshal(lista) b, _ := json.Marshal(lista)
return template.JS(b) return template.JS(b)
@@ -185,7 +186,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
} }
nalog.Ukupno = ukupno nalog.Ukupno = ukupno
id, err := h.ProdajaRepo.Kreiraj(r.Context(), &nalog, stavke) id, err := h.ProdajaRepo.Kreiraj(r.Context(), &nalog, stavke, &k.ID)
if err != nil { if err != nil {
var errStanje *appdb.ErrNedovoljnoKolicine var errStanje *appdb.ErrNedovoljnoKolicine
if errors.As(err, &errStanje) { if errors.As(err, &errStanje) {
@@ -336,10 +337,15 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
} }
} }
nalog.Napomena = strings.TrimSpace(r.FormValue("napomena")) nalog.Napomena = strings.TrimSpace(r.FormValue("napomena"))
nalog.NacinPlacanja = r.FormValue("nacin_placanja")
if nalog.NacinPlacanja != "gotovina" && nalog.NacinPlacanja != "kartica" && nalog.NacinPlacanja != "prenos" {
nalog.NacinPlacanja = "gotovina"
}
artikalIDovi := r.Form["artikal_id[]"] artikalIDovi := r.Form["artikal_id[]"]
kolicine := r.Form["kolicina[]"] kolicine := r.Form["kolicina[]"]
cene := r.Form["cena_po_komadu[]"] cene := r.Form["cena_po_komadu[]"]
pdvStope := r.Form["pdv_stopa[]"]
if len(artikalIDovi) == 0 { if len(artikalIDovi) == 0 {
return nalog, nil, "Prodaja mora imati najmanje jednu stavku." return nalog, nil, "Prodaja mora imati najmanje jednu stavku."
@@ -366,17 +372,23 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
return nalog, nil, "Cena mora biti pozitivan broj." return nalog, nil, "Cena mora biti pozitivan broj."
} }
var pdvStopa float64
if i < len(pdvStope) {
pdvStopa, _ = strconv.ParseFloat(strings.TrimSpace(pdvStope[i]), 64)
}
stavke = append(stavke, model.StavkaProdaje{ stavke = append(stavke, model.StavkaProdaje{
ArtikalID: artikalID, ArtikalID: artikalID,
Kolicina: kolicina, Kolicina: kolicina,
CenaPoKomadu: cena, CenaPoKomadu: cena,
PdvStopa: pdvStopa,
}) })
} }
return nalog, stavke, "" return nalog, stavke, ""
} }
// StornoProdaje stornira prodajni nalog: vraća artikle na stanje i briše nalog // StornoProdaje stornira prodajni nalog: vraća artikle na stanje i označava nalog kao storniran
func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) { func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context()) k := middleware.KorisnikIzKonteksta(r.Context())
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.storno") { if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.storno") {
@@ -388,12 +400,19 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return return
} }
if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri storniranju naloga", http.StatusInternalServerError) http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
razlog := strings.TrimSpace(r.FormValue("razlog"))
if err := h.ProdajaRepo.Storno(r.Context(), id, razlog, &k.ID); err != nil {
log.Printf("greška pri storniranju naloga: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri storniranju. Možda je nalog već storniran.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.") middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
http.Redirect(w, r, "/prodaja", http.StatusSeeOther) http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
} }
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje // renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
+145 -5
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
appdbPkg "ntech/internal/db"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
@@ -30,6 +31,7 @@ type PodaciFormeNaloga struct {
model.PodaciStranice model.PodaciStranice
Nalog model.ServisniNalog Nalog model.ServisniNalog
Klijenti []model.Klijent Klijenti []model.Klijent
Tehnicari []model.Korisnik
SviStatusi []string SviStatusi []string
Greska string Greska string
Izmena bool Izmena bool
@@ -40,6 +42,9 @@ type PodaciDetaljiNaloga struct {
model.PodaciStranice model.PodaciStranice
Nalog model.ServisniNalog Nalog model.ServisniNalog
KlijentNaziv string KlijentNaziv string
TehnicarNaziv string
ServisniDelovi []model.ServisniDeoSaArtiklom
Artikli []model.ArtikalSaKategorijom
Sacuvano bool Sacuvano bool
} }
@@ -96,6 +101,12 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) {
return return
} }
tehnicari, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
http.Error(w, "Greška pri učitavanju tehničara", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis" ps.Stranica = "servis"
ps.NaslovStranice = "Novi nalog" ps.NaslovStranice = "Novi nalog"
@@ -103,6 +114,7 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Nalog: model.ServisniNalog{BrojNaloga: brojNaloga, Status: model.StatusPrimljeno}, Nalog: model.ServisniNalog{BrojNaloga: brojNaloga, Status: model.StatusPrimljeno},
Klijenti: klijenti, Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi, SviStatusi: model.SviStatusi,
Izmena: false, Izmena: false,
}) })
@@ -124,6 +136,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
if greska != "" { if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis" ps.Stranica = "servis"
ps.NaslovStranice = "Novi nalog" ps.NaslovStranice = "Novi nalog"
@@ -131,6 +144,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Nalog: nalog, Nalog: nalog,
Klijenti: klijenti, Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi, SviStatusi: model.SviStatusi,
Greska: greska, Greska: greska,
Izmena: false, Izmena: false,
@@ -143,6 +157,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
log.Printf("greška pri čuvanju naloga: %v", err) log.Printf("greška pri čuvanju naloga: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis" ps.Stranica = "servis"
ps.NaslovStranice = "Novi nalog" ps.NaslovStranice = "Novi nalog"
@@ -150,6 +165,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Nalog: nalog, Nalog: nalog,
Klijenti: klijenti, Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi, SviStatusi: model.SviStatusi,
Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.", Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.",
Izmena: false, Izmena: false,
@@ -186,6 +202,12 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
return return
} }
tehnicari, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
http.Error(w, "Greška pri učitavanju tehničara", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis" ps.Stranica = "servis"
ps.NaslovStranice = "Izmeni nalog" ps.NaslovStranice = "Izmeni nalog"
@@ -193,6 +215,7 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Nalog: *nalog, Nalog: *nalog,
Klijenti: klijenti, Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi, SviStatusi: model.SviStatusi,
Izmena: true, Izmena: true,
}) })
@@ -220,6 +243,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
if greska != "" { if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
nalog.ID = id nalog.ID = id
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis" ps.Stranica = "servis"
@@ -228,6 +252,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Nalog: nalog, Nalog: nalog,
Klijenti: klijenti, Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi, SviStatusi: model.SviStatusi,
Greska: greska, Greska: greska,
Izmena: true, Izmena: true,
@@ -240,6 +265,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
log.Printf("greška pri čuvanju izmene naloga: %v", err) log.Printf("greška pri čuvanju izmene naloga: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis" ps.Stranica = "servis"
ps.NaslovStranice = "Izmeni nalog" ps.NaslovStranice = "Izmeni nalog"
@@ -247,6 +273,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Nalog: nalog, Nalog: nalog,
Klijenti: klijenti, Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi, SviStatusi: model.SviStatusi,
Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.", Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.",
Izmena: true, Izmena: true,
@@ -278,7 +305,7 @@ func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis?obrisan=1", http.StatusSeeOther) http.Redirect(w, r, "/servis?obrisan=1", http.StatusSeeOther)
} }
// DetaljiNaloga prikazuje sve podatke jednog servisnog naloga // DetaljiNaloga prikazuje sve podatke jednog servisnog naloga sa ugrađenim delovima
func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id")) id, err := parseID(chi.URLParam(r, "id"))
if err != nil { if err != nil {
@@ -302,12 +329,27 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
if nalog.KlijentID != nil { if nalog.KlijentID != nil {
klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID) klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
if err == nil { if err == nil {
if klijent.NazivFirme != "" { klijentNaziv = klijent.PunoIme()
klijentNaziv = klijent.NazivFirme
} else {
klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime)
} }
} }
tehnicarNaziv := ""
if nalog.TehnicarID != nil {
tehnicar, err := h.KorisniciRepo.DohvatiPoID(r.Context(), *nalog.TehnicarID)
if err == nil {
tehnicarNaziv = tehnicar.KorisnickoIme
}
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
if err != nil {
log.Printf("greška pri učitavanju delova: %v", err)
}
appdb := appdbPkg.ArtikalFilter{}
artikli, err := h.Artikli.Lista(r.Context(), appdb)
if err != nil {
log.Printf("greška pri učitavanju artikala: %v", err)
} }
ps := h.popuniPodaciStranice(r, podesavanja) ps := h.popuniPodaciStranice(r, podesavanja)
@@ -317,12 +359,96 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps, PodaciStranice: ps,
Nalog: *nalog, Nalog: *nalog,
KlijentNaziv: klijentNaziv, KlijentNaziv: klijentNaziv,
TehnicarNaziv: tehnicarNaziv,
ServisniDelovi: delovi,
Artikli: artikli,
Sacuvano: r.URL.Query().Get("sacuvano") == "1", Sacuvano: r.URL.Query().Get("sacuvano") == "1",
} }
h.renderujTemplate(w, "servis_detalji", podaci) h.renderujTemplate(w, "servis_detalji", podaci)
} }
// DodajDeloNalogu prima POST formu i dodaje artikal kao deo servisnog naloga
func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.izmeni") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
nalogID, 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
}
artikalID, err := strconv.ParseInt(r.FormValue("artikal_id"), 10, 64)
if err != nil || artikalID <= 0 {
middleware.SetFlash(w, r, h.DB, "greska", "Neispravan artikal.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
}
kolicina, err := strconv.Atoi(r.FormValue("kolicina"))
if err != nil || kolicina <= 0 {
middleware.SetFlash(w, r, h.DB, "greska", "Količina mora biti pozitivan broj.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
}
cena, err := strconv.ParseFloat(r.FormValue("cena_komada"), 64)
if err != nil || cena < 0 {
middleware.SetFlash(w, r, h.DB, "greska", "Cena mora biti pozitivan broj.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
}
if _, err := h.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID); err != nil {
log.Printf("greška pri dodavanju dela: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela. Proverite stanje na magacinu.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je dodat.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
}
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga
func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.izmeni") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
nalogID, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
deoID, err := parseID(chi.URLParam(r, "deo_id"))
if err != nil {
http.Error(w, "Neispravan ID dela", http.StatusBadRequest)
return
}
if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
log.Printf("greška pri brisanju dela: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.")
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je uklonjen.")
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
}
// parseFormuNaloga čita i validira polja iz HTTP forme // parseFormuNaloga čita i validira polja iz HTTP forme
func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) { func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
uredjaj := strings.TrimSpace(r.FormValue("uredjaj")) uredjaj := strings.TrimSpace(r.FormValue("uredjaj"))
@@ -355,6 +481,13 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
} }
} }
// opcioni tehničar
if tidStr := r.FormValue("tehnicar_id"); tidStr != "" {
if tid, err := strconv.ParseInt(tidStr, 10, 64); err == nil {
nalog.TehnicarID = &tid
}
}
// opcione cene — prazno polje ostaje nil // opcione cene — prazno polje ostaje nil
nalog.CenaOd = parseOpcionuCenu(r.FormValue("cena_od")) nalog.CenaOd = parseOpcionuCenu(r.FormValue("cena_od"))
nalog.CenaDo = parseOpcionuCenu(r.FormValue("cena_do")) nalog.CenaDo = parseOpcionuCenu(r.FormValue("cena_do"))
@@ -368,6 +501,13 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
} }
} }
// opcioni datum garancije
if gd := strings.TrimSpace(r.FormValue("garancija_do")); gd != "" {
if t, err := time.Parse("2006-01-02", gd); err == nil {
nalog.GarancijaDo = &t
}
}
return nalog, "" return nalog, ""
} }
+12
View File
@@ -11,11 +11,23 @@ type Artikal struct {
Kolicina int Kolicina int
KolicinMin int KolicinMin int
Lokacija string Lokacija string
NabavnaCena float64
ProdajnaCena float64 ProdajnaCena float64
PdvStopa float64
Napomena string Napomena string
DatumUnosa time.Time DatumUnosa time.Time
} }
// CenaBezPdv izračunava prodajnu cenu bez PDV-a
func (a Artikal) CenaBezPdv() float64 {
return a.ProdajnaCena / (1 + a.PdvStopa/100)
}
// PdvIznos izračunava iznos PDV-a za jednu jedinicu
func (a Artikal) PdvIznos() float64 {
return a.ProdajnaCena - a.CenaBezPdv()
}
// Kategorija predstavlja kategoriju artikala // Kategorija predstavlja kategoriju artikala
type Kategorija struct { type Kategorija struct {
ID int64 ID int64
+14 -1
View File
@@ -1,12 +1,17 @@
package model package model
import "time" import (
"strings"
"time"
)
// Klijent predstavlja jednog klijenta — fizičko lice ili firmu // Klijent predstavlja jednog klijenta — fizičko lice ili firmu
type Klijent struct { type Klijent struct {
ID int64 ID int64
Tip string
Ime string Ime string
Prezime string Prezime string
JMBG string
NazivFirme string NazivFirme string
PIB string PIB string
Telefon string Telefon string
@@ -14,3 +19,11 @@ type Klijent struct {
Napomena string Napomena string
DatumUnosa time.Time DatumUnosa time.Time
} }
// PunoIme vraća ime i prezime za fizičko lice, ili naziv firme za pravno
func (k Klijent) PunoIme() string {
if k.Tip == "pravno" {
return k.NazivFirme
}
return strings.TrimSpace(k.Ime + " " + k.Prezime)
}
+27
View File
@@ -0,0 +1,27 @@
package model
import "time"
// Tipovi magacinskih promena
const (
PromenaUlazNabavka = "ulaz_nabavka"
PromenaIzlazProdaja = "izlaz_prodaja"
PromenaIzlazServis = "izlaz_servis"
PromenaPovracaj = "povracaj"
PromenaKorekcija = "korekcija"
)
// MagacinskaPromenaSaDetaljem je promena stanja artikla sa nazivom artikla
type MagacinskaPromenaSaDetaljem struct {
ID int64
ArtikalID int64
ArtikalNaziv string
TipPromene string
ReferentniID int64
PromenaKolicine int
StanjePre int
StanjePosle int
KorisnikID *int64
Napomena string
Datum time.Time
}
+6
View File
@@ -9,6 +9,9 @@ type ProdajniNalog struct {
BrojNaloga string BrojNaloga string
Napomena string Napomena string
Ukupno float64 Ukupno float64
NacinPlacanja string
Stornirano bool
RazlogStorniranja string
Datum time.Time Datum time.Time
} }
@@ -20,6 +23,9 @@ type StavkaProdaje struct {
Kolicina int Kolicina int
CenaPoKomadu float64 CenaPoKomadu float64
Ukupno float64 Ukupno float64
PdvStopa float64
PdvIznos float64
CenaBezPdv float64
} }
// ProdajniNalogSaDetaljem je nalog sa nazivom klijenta — za prikaz u listi // ProdajniNalogSaDetaljem je nalog sa nazivom klijenta — za prikaz u listi
+31
View File
@@ -29,6 +29,7 @@ var SviStatusi = []string{
type ServisniNalog struct { type ServisniNalog struct {
ID int64 ID int64
KlijentID *int64 KlijentID *int64
TehnicarID *int64
BrojNaloga string BrojNaloga string
Uredjaj string Uredjaj string
SerijskiBroj string SerijskiBroj string
@@ -39,10 +40,32 @@ type ServisniNalog struct {
CenaKonacna *float64 CenaKonacna *float64
Avans *float64 Avans *float64
Napomena string Napomena string
GarancijaDo *time.Time
DatumPrijema time.Time DatumPrijema time.Time
DatumZavrsetka *time.Time DatumZavrsetka *time.Time
} }
// ServisniDeo predstavlja jedan artikal ugrađen u servisni nalog
type ServisniDeo struct {
ID int64
NalogID int64
ArtikalID int64
Kolicina int
CenaKomada float64
Datum time.Time
}
// Ukupno vraća ukupnu vrednost dela (kolicina × cena)
func (d ServisniDeo) Ukupno() float64 {
return float64(d.Kolicina) * d.CenaKomada
}
// ServisniDeoSaArtiklom je servisni deo sa nazivom artikla — za prikaz
type ServisniDeoSaArtiklom struct {
ServisniDeo
ArtikalNaziv string
}
// ServisniNalogSaKlijentom proširuje ServisniNalog sa nazivom klijenta za prikaz u listi // ServisniNalogSaKlijentom proširuje ServisniNalog sa nazivom klijenta za prikaz u listi
type ServisniNalogSaKlijentom struct { type ServisniNalogSaKlijentom struct {
ServisniNalog ServisniNalog
@@ -57,6 +80,14 @@ func (n ServisniNalog) KlijentIDVrednost() int64 {
return *n.KlijentID return *n.KlijentID
} }
// TehnicarIDVrednost vraća vrednost TehnicarID pointera, ili 0 ako je nil
func (n ServisniNalog) TehnicarIDVrednost() int64 {
if n.TehnicarID == nil {
return 0
}
return *n.TehnicarID
}
// CenaOdStr vraća formatiranu procenu od, ili prazan string ako nije uneta // CenaOdStr vraća formatiranu procenu od, ili prazan string ako nije uneta
func (n ServisniNalog) CenaOdStr() string { func (n ServisniNalog) CenaOdStr() string {
if n.CenaOd == nil { if n.CenaOd == nil {
+52
View File
@@ -0,0 +1,52 @@
-- ============================================
-- Faza 1: POS proširenje — PDV, magacinska revizija, servisni delovi
-- ============================================
-- 1. Artikli — dodajemo PDV stopu (nabavna_cena već postoji)
ALTER TABLE artikli ADD COLUMN pdv_stopa REAL NOT NULL DEFAULT 20.0;
-- 2. Stavke prodaje — PDV po stavci (za tačan obračun i buduće fiskalne račune)
ALTER TABLE stavke_prodaje ADD COLUMN pdv_stopa REAL NOT NULL DEFAULT 20.0;
ALTER TABLE stavke_prodaje ADD COLUMN pdv_iznos REAL NOT NULL DEFAULT 0;
ALTER TABLE stavke_prodaje ADD COLUMN cena_bez_pdv REAL NOT NULL DEFAULT 0;
-- 3. Prodajni nalozi — način plaćanja i storno
ALTER TABLE prodajni_nalozi ADD COLUMN nacin_placanja TEXT NOT NULL DEFAULT 'gotovina'
CHECK (nacin_placanja IN ('gotovina', 'kartica', 'prenos'));
ALTER TABLE prodajni_nalozi ADD COLUMN stornirano INTEGER NOT NULL DEFAULT 0;
ALTER TABLE prodajni_nalozi ADD COLUMN razlog_storniranja TEXT;
-- 4. Servisni nalozi — tehničar i garancija
ALTER TABLE servisni_nalozi ADD COLUMN tehnicar_id INTEGER REFERENCES korisnici(id) ON DELETE SET NULL;
ALTER TABLE servisni_nalozi ADD COLUMN garancija_do DATE;
-- 5. Klijenti — JMBG i tip (fizičko/pravno lice)
ALTER TABLE klijenti ADD COLUMN jmbg TEXT;
ALTER TABLE klijenti ADD COLUMN tip TEXT NOT NULL DEFAULT 'fizicko'
CHECK (tip IN ('fizicko', 'pravno'));
-- 6. Magacinska revizija — trag svake promene stanja artikla
CREATE TABLE IF NOT EXISTS magacinske_promene (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artikal_id INTEGER NOT NULL REFERENCES artikli(id) ON DELETE RESTRICT,
tip_promene TEXT NOT NULL CHECK (tip_promene IN (
'ulaz_nabavka', 'izlaz_prodaja', 'izlaz_servis', 'povracaj', 'korekcija'
)),
referentni_id INTEGER NOT NULL,
promena_kolicine INTEGER NOT NULL,
stanje_pre INTEGER NOT NULL,
stanje_posle INTEGER NOT NULL,
korisnik_id INTEGER REFERENCES korisnici(id) ON DELETE SET NULL,
napomena TEXT,
datum DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 7. Servisni delovi — artikli ugrađeni u servisni nalog
CREATE TABLE IF NOT EXISTS servisni_delovi (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nalog_id INTEGER NOT NULL REFERENCES servisni_nalozi(id) ON DELETE CASCADE,
artikal_id INTEGER NOT NULL REFERENCES artikli(id) ON DELETE RESTRICT,
kolicina INTEGER NOT NULL DEFAULT 1 CHECK (kolicina > 0),
cena_komada REAL NOT NULL,
datum DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+4
View File
@@ -0,0 +1,4 @@
-- Dodaje nabavnu cenu u artikle ako već ne postoji.
-- Potrebno jer je migration 002 mogla biti preskočena na bazama koje su
-- imale tabelu artikli pre uvođenja te migracije.
ALTER TABLE artikli ADD COLUMN nabavna_cena REAL NOT NULL DEFAULT 0;
+18 -2
View File
@@ -208,8 +208,22 @@ body {
/* accordion podmeni u sidebaru */ /* accordion podmeni u sidebaru */
.nav-podmeni { .nav-podmeni {
overflow: hidden; overflow: hidden;
max-height: 0;
transition: max-height 0.22s ease-out;
}
.nav-podmeni.otvoren {
max-height: 300px;
transition: max-height 0.25s ease-in;
}
/* pri inicijalnom učitavanju sprečavamo animaciju podmenia */
.nav-podmeni.bez-tranzicije {
transition: none !important;
}
/* u skupljenom modu podmeni se nikad ne prikazuje */
.sidebar.skupljen .nav-podmeni {
max-height: 0 !important;
transition: none;
} }
/* u skupljenom modu alpine x-show kontroliše vidljivost — ne skrivamo display:none */
.nav-podstavka { .nav-podstavka {
padding-left: 48px !important; padding-left: 48px !important;
} }
@@ -256,8 +270,10 @@ body {
display: none; display: none;
} }
.nav-stavka:hover .nav-tooltip { /* specifičniji selektor pobjeđuje .sidebar.skupljen .nav-stavka span { opacity:0 } */
.sidebar.skupljen .nav-stavka:hover .nav-tooltip {
opacity: 1; opacity: 1;
pointer-events: none;
} }
/* dno sidebara */ /* dno sidebara */
+51 -3
View File
@@ -1,3 +1,43 @@
// otvara/zatvara podmeni u sidebaru — funkcioniše i posle HTMX swap-a (čisti onclick, bez Alpine-a)
function ntechTogglePodmeni(btn) {
var sidebar = document.getElementById('sidebar');
// ako je sidebar skupljen, raširimo ga pre nego otvorimo podmeni
if (sidebar && sidebar.classList.contains('skupljen')) {
sidebar.classList.remove('skupljen');
localStorage.setItem('sidebar-skupljeno', 'false');
}
var podmeni = btn.nextElementSibling;
if (!podmeni) return;
podmeni.classList.toggle('otvoren');
var strelicaSvg = btn.querySelector('.nav-strelica svg');
if (strelicaSvg) {
strelicaSvg.style.transform = podmeni.classList.contains('otvoren') ? 'rotate(180deg)' : 'rotate(0deg)';
}
}
// registruje klik listenere na podmeni dugmad — sidebar se nikad ne menja, poziva se jednom
function ntechDodajPodmeniListenere() {
document.querySelectorAll('#sidebar [data-podmeni-dugme]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
ntechTogglePodmeni(btn);
});
});
}
// sprečava CSS tranziciju na podmeniima koji su već otvoreni pri inicijalnom učitavanju
// (bez ovoga, max-height animira od 0 do 300px pri svakom swap-u)
function ntechInicijalizujPodmeni() {
document.querySelectorAll('.nav-podmeni.otvoren').forEach(function(el) {
el.classList.add('bez-tranzicije');
requestAnimationFrame(function() {
requestAnimationFrame(function() {
el.classList.remove('bez-tranzicije');
});
});
});
}
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
// sidebar — podmeni "Moj profil" // sidebar — podmeni "Moj profil"
@@ -82,7 +122,7 @@ document.addEventListener('alpine:init', () => {
// forma za prodaju // forma za prodaju
Alpine.data('prodajaForma', () => ({ Alpine.data('prodajaForma', () => ({
stavke: [{artikal_id: '', kolicina: 1, cena: 0}], stavke: [{artikal_id: '', kolicina: 1, cena: 0, pdv_stopa: 20}],
artikliOpcije: [], artikliOpcije: [],
isMobile: false, isMobile: false,
init() { init() {
@@ -93,14 +133,17 @@ document.addEventListener('alpine:init', () => {
}) })
}, },
dodajStavku() { dodajStavku() {
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0}) this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, pdv_stopa: 20})
}, },
ukloniStavku(i) { ukloniStavku(i) {
if (this.stavke.length > 1) this.stavke.splice(i, 1) if (this.stavke.length > 1) this.stavke.splice(i, 1)
}, },
popuniCenu(stavka) { popuniCenu(stavka) {
const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id) const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id)
if (a) stavka.cena = a.cena if (a) {
stavka.cena = a.cena
stavka.pdv_stopa = a.pdv_stopa !== undefined ? a.pdv_stopa : 20
}
}, },
dostupnaKolicina(i) { dostupnaKolicina(i) {
const stavka = this.stavke[i] const stavka = this.stavke[i]
@@ -202,3 +245,8 @@ document.addEventListener('alpine:init', () => {
})) }))
}) })
// za prvo učitavanje (defer script) — sprečava animaciju podmenia koji su inicijalno otvoreni
ntechInicijalizujPodmeni()
// dodaje klik listenere na podmeni dugmad (data-podmeni-dugme)
ntechDodajPodmeniListenere()
+36 -14
View File
@@ -24,7 +24,7 @@
</div> </div>
</div> </div>
<nav class="sidebar-nav" aria-label="Glavna navigacija" hx-boost="true" hx-target="body" hx-swap="innerHTML"> <nav class="sidebar-nav" aria-label="Glavna navigacija" hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML">
<div class="nav-oznaka">Glavni meni</div> <div class="nav-oznaka">Glavni meni</div>
<a href="/dashboard" class="nav-stavka {{if eq .Stranica "dashboard"}}aktivan{{end}}"> <a href="/dashboard" class="nav-stavka {{if eq .Stranica "dashboard"}}aktivan{{end}}">
@@ -89,21 +89,21 @@
<div class="nav-oznaka">Nalog</div> <div class="nav-oznaka">Nalog</div>
{{if index .Dozvole "tema.lokalno"}} {{if index .Dozvole "tema.lokalno"}}
<div x-data="sidebarProfil" data-otvoren="{{if or (eq .Stranica "profil") (eq .Stranica "profil-tema")}}true{{else}}false{{end}}"> <div>
<button type="button" @click="otvoren = !otvoren" <button type="button" data-podmeni-dugme
class="nav-stavka {{if or (eq .Stranica "profil") (eq .Stranica "profil-tema")}}aktivan{{end}}" class="nav-stavka {{if or (eq .Stranica "profil") (eq .Stranica "profil-tema")}}aktivan{{end}}"
style="width:100%;background:none;border:none;cursor:pointer;"> style="width:100%;background:none;border:none;cursor:pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>Moj profil</span> <span>Moj profil</span>
<span class="nav-strelica"> <span class="nav-strelica">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="'transition:transform 0.2s;transform:' + (otvoren ? 'rotate(180deg)' : 'rotate(0deg)')"> style="transition:transform 0.2s;transform:{{if or (eq .Stranica "profil") (eq .Stranica "profil-tema")}}rotate(180deg){{else}}rotate(0deg){{end}}">
<polyline points="6 9 12 15 18 9"/> <polyline points="6 9 12 15 18 9"/>
</svg> </svg>
</span> </span>
<span class="nav-tooltip">Moj profil</span> <span class="nav-tooltip">Moj profil</span>
</button> </button>
<div class="nav-podmeni" x-show="otvoren" x-transition> <div class="nav-podmeni {{if or (eq .Stranica "profil") (eq .Stranica "profil-tema")}}otvoren{{end}}">
<a href="/admin/profil" class="nav-stavka nav-podstavka {{if eq .Stranica "profil"}}aktivan{{end}}"> <a href="/admin/profil" class="nav-stavka nav-podstavka {{if eq .Stranica "profil"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>Opšte</span> <span>Opšte</span>
@@ -137,32 +137,32 @@
<div class="nav-oznaka">Sistem</div> <div class="nav-oznaka">Sistem</div>
{{if index .Dozvole "podesavanja.pregled"}} {{if index .Dozvole "podesavanja.pregled"}}
<div x-data="sidebarPodesavanja" data-otvoren="{{if or (eq .Stranica "podesavanja") (eq .Stranica "dozvole")}}true{{else}}false{{end}}"> <div>
<button type="button" @click="otvoren = !otvoren" <button type="button" data-podmeni-dugme
class="nav-stavka {{if eq .Stranica "podesavanja"}}aktivan{{end}}" class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "dozvole")}}aktivan{{end}}"
style="width:100%;background:none;border:none;cursor:pointer;"> style="width:100%;background:none;border:none;cursor:pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>Podešavanja</span> <span>Podešavanja</span>
<span class="nav-strelica"> <span class="nav-strelica">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="'transition:transform 0.2s;transform:' + (otvoren ? 'rotate(180deg)' : 'rotate(0deg)')"> style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}">
<polyline points="6 9 12 15 18 9"/> <polyline points="6 9 12 15 18 9"/>
</svg> </svg>
</span> </span>
<span class="nav-tooltip">Podešavanja</span> <span class="nav-tooltip">Podešavanja</span>
</button> </button>
<div class="nav-podmeni" x-show="otvoren" x-transition> <div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "dozvole")}}otvoren{{end}}">
<a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka"> <a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>
<span>Opšte</span> <span>Opšte</span>
<span class="nav-tooltip">Opšte</span> <span class="nav-tooltip">Opšte</span>
</a> </a>
<a href="/admin/podesavanja/izgled" class="nav-stavka nav-podstavka"> <a href="/admin/podesavanja/izgled" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-izgled"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21a9 9 0 1 1 0-18c4.97 0 9 3.582 9 8 0 1.06-.474 2.078-1.318 2.828S17.626 15 16.5 15H15a2 2 0 0 0-2 2 2 2 0 0 0 2 2h.5"/><circle cx="8.5" cy="10.5" r=".5"/><circle cx="12" cy="7.5" r=".5"/><circle cx="15.5" cy="10.5" r=".5"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21a9 9 0 1 1 0-18c4.97 0 9 3.582 9 8 0 1.06-.474 2.078-1.318 2.828S17.626 15 16.5 15H15a2 2 0 0 0-2 2 2 2 0 0 0 2 2h.5"/><circle cx="8.5" cy="10.5" r=".5"/><circle cx="12" cy="7.5" r=".5"/><circle cx="15.5" cy="10.5" r=".5"/></svg>
<span>Izgled</span> <span>Izgled</span>
<span class="nav-tooltip">Izgled</span> <span class="nav-tooltip">Izgled</span>
</a> </a>
<a href="/admin/podesavanja/sistem" class="nav-stavka nav-podstavka"> <a href="/admin/podesavanja/sistem" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-sistem"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="1"/><rect x="2" y="13" width="20" height="8" rx="1"/><path d="M6 7h.01M6 17h.01"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="1"/><rect x="2" y="13" width="20" height="8" rx="1"/><path d="M6 7h.01M6 17h.01"/></svg>
<span>Sistem</span> <span>Sistem</span>
<span class="nav-tooltip">Sistem</span> <span class="nav-tooltip">Sistem</span>
@@ -181,11 +181,33 @@
</nav> </nav>
<div class="sidebar-dno"> <div class="sidebar-dno">
<a href="/odjava" class="nav-stavka"> <a href="/odjava" class="nav-stavka" hx-boost="false">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>Odjava</span> <span>Odjava</span>
<span class="nav-tooltip">Odjava</span> <span class="nav-tooltip">Odjava</span>
</a> </a>
</div> </div>
</aside> </aside>
<script>
(function() {
var sidebar = document.getElementById('sidebar');
if (!sidebar) return;
sidebar.querySelectorAll('[data-podmeni-dugme]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
if (sidebar.classList.contains('skupljen')) {
sidebar.classList.remove('skupljen');
localStorage.setItem('sidebar-skupljen', 'false');
}
var podmeni = btn.nextElementSibling;
if (!podmeni) return;
podmeni.classList.toggle('otvoren');
var svg = btn.querySelector('.nav-strelica svg');
if (svg) {
svg.style.transform = podmeni.classList.contains('otvoren') ? 'rotate(180deg)' : 'rotate(0deg)';
}
});
});
})();
</script>
{{end}} {{end}}
+17 -4
View File
@@ -41,8 +41,10 @@
(function() { (function() {
var dugme = document.getElementById('avatar-dugme'); var dugme = document.getElementById('avatar-dugme');
var meni = document.getElementById('avatar-meni'); var meni = document.getElementById('avatar-meni');
if (!dugme || !meni) return;
// premesti meni na kraj body — izlazi iz stacking contexta topbara // ukloni stari meni iz body-ja (ostao od prethodne stranice) i dodaj novi
document.querySelectorAll('body > #avatar-meni').forEach(function(el) { el.remove(); });
document.body.appendChild(meni); document.body.appendChild(meni);
function pozicioniraj() { function pozicioniraj() {
@@ -52,6 +54,7 @@
meni.style.left = 'auto'; meni.style.left = 'auto';
} }
// dugme dobija novi listener svaki put (novi element pri svakoj navigaciji)
dugme.addEventListener('click', function(e) { dugme.addEventListener('click', function(e) {
e.stopPropagation(); e.stopPropagation();
if (meni.style.display === 'none' || meni.style.display === '') { if (meni.style.display === 'none' || meni.style.display === '') {
@@ -63,13 +66,23 @@
} }
}); });
// document i window listeneri — samo jednom, ne smeju da se gomilaju
if (!window._ntechAvatarDodato) {
window._ntechAvatarDodato = true;
document.addEventListener('click', function() { document.addEventListener('click', function() {
meni.style.display = 'none'; var m = document.getElementById('avatar-meni');
if (m) m.style.display = 'none';
}); });
window.addEventListener('resize', function() { window.addEventListener('resize', function() {
if (meni.style.display !== 'none') pozicioniraj(); var m = document.getElementById('avatar-meni');
var d = document.getElementById('avatar-dugme');
if (m && d && m.style.display !== 'none') {
var r = d.getBoundingClientRect();
m.style.top = (r.bottom + 8) + 'px';
m.style.right = (window.innerWidth - r.right) + 'px';
}
}); });
}
})(); })();
</script> </script>
{{end}} {{end}}
+14 -3
View File
@@ -3,7 +3,6 @@
{{define "naslov"}}Izveštaji — NTech{{end}} {{define "naslov"}}Izveštaji — NTech{{end}}
{{define "dodatni-css"}} {{define "dodatni-css"}}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style> <style>
.izv-naslov { font-size: 13px; font-weight: 500; color: var(--tekst-sporedni); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 14px; } .izv-naslov { font-size: 13px; font-weight: 500; color: var(--tekst-sporedni); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 14px; }
.toggle-red { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; } .toggle-red { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
@@ -194,11 +193,13 @@
(function() { (function() {
var podaci = {{.GrafikonJSON}}; var podaci = {{.GrafikonJSON}};
var isDark = document.documentElement.getAttribute('data-tema') !== 'svetla'; function inicijalizujGrafikon() {
var tekstBoja = getComputedStyle(document.documentElement).getPropertyValue('--tekst-sporedni').trim() || '#6b7280'; var tekstBoja = getComputedStyle(document.documentElement).getPropertyValue('--tekst-sporedni').trim() || '#6b7280';
var ivicaBoja = getComputedStyle(document.documentElement).getPropertyValue('--ivica').trim() || '#e5e7eb'; var ivicaBoja = getComputedStyle(document.documentElement).getPropertyValue('--ivica').trim() || '#e5e7eb';
var ctx = document.getElementById('grafikon-prihod').getContext('2d'); var canvas = document.getElementById('grafikon-prihod');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var grafikon = new Chart(ctx, { var grafikon = new Chart(ctx, {
type: 'bar', type: 'bar',
data: { data: {
@@ -256,6 +257,16 @@
document.getElementById('toggle-grafikon').addEventListener('change', function() { document.getElementById('toggle-grafikon').addEventListener('change', function() {
document.getElementById('grafikon-wrapper').style.display = this.checked ? 'block' : 'none'; document.getElementById('grafikon-wrapper').style.display = this.checked ? 'block' : 'none';
}); });
} // kraj inicijalizujGrafikon
if (window.Chart) {
inicijalizujGrafikon();
} else {
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js';
s.onload = inicijalizujGrafikon;
document.head.appendChild(s);
}
})(); })();
</script> </script>
{{end}} {{end}}
+73 -32
View File
@@ -5,13 +5,23 @@
{{define "dodatni-css"}} {{define "dodatni-css"}}
<style> <style>
.greska-animacija { animation: shake 0.4s ease; } .greska-animacija { animation: shake 0.4s ease; }
.tip-radio { display:flex; gap:12px; }
.tip-radio label {
display:flex; align-items:center; gap:6px;
cursor:pointer; font-size:14px; color:var(--tekst-glavni);
padding:8px 14px; border-radius:6px; border:1px solid var(--ivica);
transition:background 0.15s, border-color 0.15s;
}
.tip-radio input[type=radio] { accent-color: var(--akcentna); }
.tip-radio label:has(input:checked) {
background:var(--akcentna-bleda); border-color:var(--akcentna);
}
</style> </style>
{{end}} {{end}}
{{define "sadrzaj"}} {{define "sadrzaj"}}
<div style="width:100%;"> <div style="width:100%;">
<!-- nazad dugme -->
<a href="/klijenti" class="nazad-link"> <a href="/klijenti" class="nazad-link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
Nazad na klijente Nazad na klijente
@@ -29,67 +39,84 @@
{{end}} {{end}}
<form method="POST" action="{{if .Izmena}}/klijenti/izmeni/{{.Klijent.ID}}{{else}}/klijenti/novi{{end}}"> <form method="POST" action="{{if .Izmena}}/klijenti/izmeni/{{.Klijent.ID}}{{else}}/klijenti/novi{{end}}">
<div style="display:flex;flex-direction:column;gap:14px;"> <input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<div style="display:flex;flex-direction:column;gap:18px;">
<!-- ime i prezime --> <!-- tip klijenta -->
<div> <div>
<div style="font-size:12px;font-weight:500;color:var(--tekst-sporedni);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px;"> <div class="sekcija-naslov">Tip klijenta</div>
<div class="tip-radio">
<label>
<input type="radio" name="tip" value="fizicko"
{{if or (eq .Klijent.Tip "fizicko") (eq .Klijent.Tip "")}}checked{{end}}
onchange="promenaTipa(this.value)">
Fizičko lice Fizičko lice
</label>
<label>
<input type="radio" name="tip" value="pravno"
{{if eq .Klijent.Tip "pravno"}}checked{{end}}
onchange="promenaTipa(this.value)">
Pravno lice
</label>
</div> </div>
</div>
<!-- sekcija fizičko lice -->
<div id="sec-fizicko" style="display:flex;flex-direction:column;gap:12px;">
<div class="sekcija-naslov">Ime i prezime</div>
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> <div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div> <div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Ime</label> <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">
Ime <span id="ime-oznaka" style="color:#dc2626;">*</span>
</label>
<input type="text" name="ime" value="{{.Klijent.Ime}}" <input type="text" name="ime" value="{{.Klijent.Ime}}"
placeholder="npr. Marko" placeholder="npr. Marko" style="width:100%;">
style="width:100%;">
</div> </div>
<div> <div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Prezime</label> <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Prezime</label>
<input type="text" name="prezime" value="{{.Klijent.Prezime}}" <input type="text" name="prezime" value="{{.Klijent.Prezime}}"
placeholder="npr. Petrović" placeholder="npr. Petrović" style="width:100%;">
style="width:100%;">
</div> </div>
</div> </div>
<div style="max-width:260px;">
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">JMBG</label>
<input type="text" name="jmbg" value="{{.Klijent.JMBG}}"
placeholder="13 cifara" maxlength="13" style="width:100%;">
</div>
</div> </div>
<!-- naziv firme i pib --> <!-- sekcija pravno lice -->
<div> <div id="sec-pravno" style="display:none;flex-direction:column;gap:12px;">
<div style="font-size:12px;font-weight:500;color:var(--tekst-sporedni);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px;"> <div class="sekcija-naslov">Firma</div>
Firma
</div>
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> <div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div> <div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Naziv firme</label> <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">
Naziv firme <span style="color:#dc2626;">*</span>
</label>
<input type="text" name="naziv_firme" value="{{.Klijent.NazivFirme}}" <input type="text" name="naziv_firme" value="{{.Klijent.NazivFirme}}"
placeholder="npr. TechSolutions d.o.o." placeholder="npr. TechSolutions d.o.o." style="width:100%;">
style="width:100%;">
</div> </div>
<div> <div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">PIB</label> <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">PIB</label>
<input type="text" name="pib" value="{{.Klijent.PIB}}" <input type="text" name="pib" value="{{.Klijent.PIB}}"
placeholder="npr. 123456789" placeholder="npr. 123456789" style="width:100%;">
style="width:100%;">
</div> </div>
</div> </div>
</div> </div>
<!-- telefon i e-pošta --> <!-- kontakt -->
<div> <div>
<div style="font-size:12px;font-weight:500;color:var(--tekst-sporedni);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px;"> <div class="sekcija-naslov">Kontakt</div>
Kontakt
</div>
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> <div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div> <div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Telefon</label> <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Telefon</label>
<input type="text" name="telefon" value="{{.Klijent.Telefon}}" <input type="text" name="telefon" value="{{.Klijent.Telefon}}"
placeholder="npr. 060 123 4567" placeholder="npr. 060 123 4567" style="width:100%;">
style="width:100%;">
</div> </div>
<div> <div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">E-pošta</label> <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">E-pošta</label>
<input type="text" name="email" value="{{.Klijent.Email}}" <input type="text" name="email" value="{{.Klijent.Email}}"
placeholder="npr. marko@example.com" placeholder="npr. marko@example.com" style="width:100%;">
style="width:100%;">
</div> </div>
</div> </div>
</div> </div>
@@ -102,11 +129,6 @@
style="width:100%;resize:vertical;">{{.Klijent.Napomena}}</textarea> style="width:100%;resize:vertical;">{{.Klijent.Napomena}}</textarea>
</div> </div>
<!-- obaveštenje o obaveznim poljima -->
<div style="font-size:12px;color:var(--tekst-sporedni);">
Mora biti uneto ime i prezime <em>ili</em> naziv firme.
</div>
<!-- dugmad --> <!-- dugmad -->
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:6px;"> <div style="display:flex;justify-content:flex-end;gap:10px;margin-top:6px;">
<a href="/klijenti" class="btn-sekundarno">Odustani</a> <a href="/klijenti" class="btn-sekundarno">Odustani</a>
@@ -119,4 +141,23 @@
</form> </form>
</div> </div>
</div> </div>
<script>
function promenaTipa(tip) {
var secFiz = document.getElementById('sec-fizicko');
var secPrav = document.getElementById('sec-pravno');
if (tip === 'pravno') {
secFiz.style.display = 'none';
secPrav.style.display = 'flex';
} else {
secFiz.style.display = 'flex';
secPrav.style.display = 'none';
}
}
// pri učitavanju prikazujemo ispravnu sekciju
(function() {
var checked = document.querySelector('input[name="tip"]:checked');
if (checked) promenaTipa(checked.value);
})();
</script>
{{end}} {{end}}
+70 -103
View File
@@ -43,15 +43,16 @@
padding-bottom: 14px; padding-bottom: 14px;
border-bottom: 0.5px solid var(--ivica); border-bottom: 0.5px solid var(--ivica);
"> ">
<span <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
style=" <span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;">
font-size: 20px;
font-weight: 600;
color: var(--tekst-glavni);
font-family: monospace;
">
{{.Nalog.BrojNaloga}} {{.Nalog.BrojNaloga}}
</span> </span>
{{if .Nalog.Stornirano}}
<span style="display:inline-block;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:500;background:rgba(220,38,38,0.12);color:#dc2626;">
Stornirano
</span>
{{end}}
</div>
<a <a
href="/prodaja/{{.Nalog.ID}}/stampa" href="/prodaja/{{.Nalog.ID}}/stampa"
target="_blank" target="_blank"
@@ -110,18 +111,26 @@
</div> </div>
</div> </div>
<div> <div>
<div <div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Način plaćanja</div>
style=" <div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">
font-size: 12px; {{if eq .Nalog.NacinPlacanja "gotovina"}}Gotovina
color: var(--tekst-sporedni); {{else if eq .Nalog.NacinPlacanja "kartica"}}Platna kartica
margin-bottom: 4px; {{else if eq .Nalog.NacinPlacanja "prenos"}}Bankarski prenos
"> {{else}}—{{end}}
Ukupno
</div> </div>
<div style="font-size: 20px; font-weight: 600; color: var(--sb-akcent)"> </div>
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ukupno</div>
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">
{{printf "%.2f" .Nalog.Ukupno}} din {{printf "%.2f" .Nalog.Ukupno}} din
</div> </div>
</div> </div>
{{if .Nalog.RazlogStorniranja}}
<div style="grid-column:1/-1;">
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Razlog storniranja</div>
<div style="font-size:13px;color:#dc2626;">{{.Nalog.RazlogStorniranja}}</div>
</div>
{{end}}
{{if .Nalog.Napomena}} {{if .Nalog.Napomena}}
<div style="grid-column: 1/-1"> <div style="grid-column: 1/-1">
<div <div
@@ -184,15 +193,10 @@
"> ">
Cena/kom Cena/kom
</th> </th>
<th <th style="padding:10px 20px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:80px;">
style=" PDV %
padding: 10px 20px; </th>
text-align: right; <th style="padding:10px 20px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);width:120px;">
font-size: 12px;
font-weight: 500;
color: var(--tekst-sporedni);
width: 120px;
">
Ukupno Ukupno
</th> </th>
</tr> </tr>
@@ -200,105 +204,68 @@
<tbody> <tbody>
{{range .Stavke}} {{range .Stavke}}
<tr style="border-bottom: 0.5px solid var(--ivica)"> <tr style="border-bottom: 0.5px solid var(--ivica)">
<td <td style="padding:10px 20px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
style=" <td style="padding:10px 20px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
padding: 10px 20px; <td style="padding:10px 20px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .CenaPoKomadu}} din</td>
font-size: 14px; <td style="padding:10px 20px;text-align:center;font-size:13px;color:var(--tekst-sporedni);">
color: var(--tekst-glavni); {{if .PdvStopa}}{{printf "%.0f" .PdvStopa}}%{{else}}—{{end}}
">
{{.ArtikalNaziv}}
</td>
<td
style="
padding: 10px 20px;
text-align: center;
font-size: 14px;
color: var(--tekst-glavni);
">
{{.Kolicina}}
</td>
<td
style="
padding: 10px 20px;
text-align: right;
font-size: 14px;
color: var(--tekst-sporedni);
">
{{printf "%.2f" .CenaPoKomadu}} din
</td>
<td
style="
padding: 10px 20px;
text-align: right;
font-size: 14px;
font-weight: 500;
color: var(--tekst-glavni);
">
{{printf "%.2f" .Ukupno}} din
</td> </td>
<td style="padding:10px 20px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
<tfoot> <tfoot>
<tr style="border-top: 0.5px solid var(--ivica)"> <tr style="border-top: 0.5px solid var(--ivica)">
<td <td colspan="4" style="padding:12px 20px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-sporedni);">Ukupno:</td>
colspan="3" <td style="padding:12px 20px;text-align:right;font-size:16px;font-weight:600;color:var(--sb-akcent);">{{printf "%.2f" .Nalog.Ukupno}} din</td>
style="
padding: 12px 20px;
text-align: right;
font-size: 13px;
font-weight: 500;
color: var(--tekst-sporedni);
">
Ukupno:
</td>
<td
style="
padding: 12px 20px;
text-align: right;
font-size: 16px;
font-weight: 600;
color: var(--sb-akcent);
">
{{printf "%.2f" .Nalog.Ukupno}} din
</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
</div> </div>
{{if index $.Dozvole "prodaja.obrisi"}} <!-- storno i brisanje -->
<!-- zona za brisanje --> <div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
<div class="kartica detalji-kartica animiraj" style="border-color: #dc262633"> <div style="display:flex;flex-direction:column;gap:16px;">
<div
style="display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap"> {{if and (index $.Dozvole "prodaja.storno") (not .Nalog.Stornirano) }}
<div style="flex: 1; min-width: 200px"> <div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
<div <div style="flex:1;min-width:200px;">
style=" <div style="font-size:14px;font-weight:500;color:#f97316;margin-bottom:4px;">Storniranje naloga</div>
font-size: 14px; <div style="font-size:13px;color:var(--tekst-sporedni);">
font-weight: 500; Storno vraća sve artikle na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
color: #dc2626;
margin-bottom: 4px;
">
Brisanje naloga
</div> </div>
<div style="font-size: 13px; color: var(--tekst-sporedni)"> </div>
Brisanje je trajno. Količine artikala biće vraćene na stanje u <form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
magacinu. <input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="text" name="razlog" placeholder="Razlog storniranja (opciono)" style="width:240px;">
<button type="submit" class="btn-primarno" style="background:#f97316;"
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}?">
Storniraj nalog
</button>
</form>
</div>
{{end}}
{{if index $.Dozvole "prodaja.obrisi"}}
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-top:14px;border-top:0.5px solid var(--ivica);">
<div style="flex:1;min-width:200px;">
<div style="font-size:14px;font-weight:500;color:#dc2626;margin-bottom:4px;">Brisanje naloga</div>
<div style="font-size:13px;color:var(--tekst-sporedni);">
Brisanje je trajno.{{if not .Nalog.Stornirano}} Količine artikala biće vraćene na stanje u magacinu.{{end}}
</div> </div>
</div> </div>
<form method="POST" action="/prodaja/obrisi/{{.Nalog.ID}}"> <form method="POST" action="/prodaja/obrisi/{{.Nalog.ID}}">
<button <input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
type="submit" <button type="submit" class="btn-primarno" style="background:#dc2626;"
class="btn-primarno" data-potvrda="Da li ste sigurni da želite da obrišete nalog {{.Nalog.BrojNaloga}}?">
style="background: #dc2626"
data-potvrda="Da li ste sigurni da želite da obrišete nalog {{.Nalog.BrojNaloga}}? Količine artikala biće vraćene na stanje.">
Obriši nalog Obriši nalog
</button> </button>
</form> </form>
</div> </div>
</div>
{{end}} {{end}}
</div>
</div>
</div> </div>
{{end}} {{end}}
+16 -21
View File
@@ -49,35 +49,28 @@
style="font-size: 16px; font-weight: 500; color: var(--tekst-glavni)">Nova prodaja</span> style="font-size: 16px; font-weight: 500; color: var(--tekst-glavni)">Nova prodaja</span>
</div> </div>
<div style="display: flex; flex-direction: column; gap: 14px"> <div style="display: flex; flex-direction: column; gap: 14px">
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div> <div>
<label <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Klijent</label>
style="
font-size: 13px;
color: var(--tekst-sporedni);
display: block;
margin-bottom: 6px;
">Klijent</label>
<select name="klijent_id" style="width: 100%"> <select name="klijent_id" style="width: 100%">
<option value="">— bez klijenta —</option> <option value="">— bez klijenta —</option>
{{range .Klijenti}} {{range .Klijenti}}
<option value="{{.ID}}"> <option value="{{.ID}}">{{.PunoIme}}</option>
{{if .NazivFirme}}{{.NazivFirme}}{{else}}{{.Ime}}
{{.Prezime}}{{end}}
</option>
{{end}} {{end}}
</select> </select>
</div> </div>
<div> <div>
<label <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Način plaćanja</label>
style=" <select name="nacin_placanja" style="width:100%;">
font-size: 13px; <option value="gotovina">Gotovina</option>
color: var(--tekst-sporedni); <option value="kartica">Platna kartica</option>
display: block; <option value="prenos">Bankarski prenos</option>
margin-bottom: 6px; </select>
">Napomena</label> </div>
<textarea </div>
name="napomena" <div>
rows="2" <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Napomena</label>
<textarea name="napomena" rows="2"
placeholder="Interna napomena o prodaji..." placeholder="Interna napomena o prodaji..."
style="width: 100%; resize: vertical"></textarea> style="width: 100%; resize: vertical"></textarea>
</div> </div>
@@ -163,6 +156,7 @@
<template x-for="(stavka, i) in stavke" :key="i"> <template x-for="(stavka, i) in stavke" :key="i">
<tr style="border-bottom: 0.5px solid var(--ivica)"> <tr style="border-bottom: 0.5px solid var(--ivica)">
<td style="padding: 8px 10px"> <td style="padding: 8px 10px">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa">
<select <select
:name="'artikal_id[]'" :name="'artikal_id[]'"
x-model="stavka.artikal_id" x-model="stavka.artikal_id"
@@ -322,6 +316,7 @@
</button> </button>
</div> </div>
<div style="display: flex; flex-direction: column; gap: 10px"> <div style="display: flex; flex-direction: column; gap: 10px">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa">
<div> <div>
<label <label
style=" style="
+86 -5
View File
@@ -68,12 +68,24 @@
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</div> <div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</div>
</div> </div>
{{end}} {{end}}
{{if .Nalog.GarancijaDo}}
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija do</div>
<div style="font-size:14px;color:#16a34a;font-weight:500;">{{.Nalog.GarancijaDo.Format "02.01.2006."}}</div>
</div>
{{end}}
<div> <div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Klijent</div> <div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Klijent</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);"> <div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">
{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}} {{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}}
</div> </div>
</div> </div>
{{if .TehnicarNaziv}}
<div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Tehničar</div>
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{.TehnicarNaziv}}</div>
</div>
{{end}}
</div> </div>
</div> </div>
@@ -149,12 +161,81 @@
</div> </div>
</div> </div>
<!-- placeholder za buduće funkcije --> <!-- ugrađeni delovi -->
<div class="kartica detalji-kartica animiraj" style="border-style:dashed;"> <div class="kartica detalji-kartica animiraj">
<div style="display:flex;align-items:center;gap:10px;color:var(--tekst-sporedni);"> <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;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Ugrađeni delovi</span>
<span style="font-size:13px;">Istorija promena statusa i vezani artikli biće dostupni u narednoj verziji.</span>
</div> </div>
<!-- forma za dodavanje dela -->
{{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/delovi" style="margin-bottom:16px;">
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<div style="display:grid;grid-template-columns:1fr 80px 120px auto;gap:10px;align-items:flex-end;">
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Artikal</label>
<select name="artikal_id" style="width:100%;" required>
<option value="">— odaberi —</option>
{{range .Artikli}}
<option value="{{.ID}}">{{.Naziv}} ({{.Kolicina}} kom)</option>
{{end}}
</select>
</div>
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Kol.</label>
<input type="number" name="kolicina" min="1" value="1" style="width:100%;" required>
</div>
<div>
<label style="font-size:12px;color:var(--tekst-sporedni);display:block;margin-bottom:4px;">Cena/kom (din)</label>
<input type="number" name="cena_komada" min="0" step="0.01" value="0" style="width:100%;" required>
</div>
<div>
<button type="submit" class="btn-primarno" style="font-size:13px;padding:8px 14px;white-space:nowrap;">+ Dodaj</button>
</div>
</div>
</form>
{{end}}
<!-- lista delova -->
{{if .ServisniDelovi}}
<table style="width:100%;border-collapse:collapse;">
<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: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>
<th style="width:50px;"></th>
</tr>
</thead>
<tbody>
{{range .ServisniDelovi}}
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</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);">{{printf "%.2f" .CenaKomada}} din</td>
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
<td style="padding:9px 10px;text-align:center;">
{{if index $.Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{$.CsrfToken}}">
<button type="submit"
data-potvrda="Ukloniti deo '{{.ArtikalNaziv}}'?"
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">
×
</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div style="font-size:13px;color:var(--tekst-sporedni);text-align:center;padding:16px 0;">
Nema ugrađenih delova za ovaj nalog.
</div>
{{end}}
</div> </div>
<!-- zona za brisanje --> <!-- zona za brisanje -->
+30 -7
View File
@@ -70,25 +70,42 @@
</div> </div>
</div> </div>
<!-- klijent --> <!-- klijent i tehničar -->
<div> <div>
<div class="sekcija-naslov">Klijent</div> <div class="sekcija-naslov">Klijent i tehničar</div>
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Klijent</label>
<select name="klijent_id" style="width:100%;"> <select name="klijent_id" style="width:100%;">
<option value="">— bez klijenta —</option> <option value="">— bez klijenta —</option>
{{range .Klijenti}} {{range .Klijenti}}
<option value="{{.ID}}" <option value="{{.ID}}"
{{if eq .ID $.Nalog.KlijentIDVrednost}}selected{{end}}> {{if eq .ID $.Nalog.KlijentIDVrednost}}selected{{end}}>
{{if .NazivFirme}}{{.NazivFirme}}{{else}}{{.Ime}} {{.Prezime}}{{end}} {{.PunoIme}}
</option> </option>
{{end}} {{end}}
</select> </select>
</div> </div>
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Tehničar</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>
<!-- status i datum završetka --> <!-- status i datumi -->
<div>
<div class="sekcija-naslov">Status</div>
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div> <div>
<div class="sekcija-naslov">Status i datumi</div>
<div class="forma-grid-4" style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;">
<div style="grid-column:span 2;">
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Status naloga</label> <label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Status naloga</label>
<select name="status" style="width:100%;"> <select name="status" style="width:100%;">
{{range .SviStatusi}} {{range .SviStatusi}}
@@ -102,6 +119,12 @@
value="{{if .Nalog.DatumZavrsetka}}{{.Nalog.DatumZavrsetka.Format "2006-01-02"}}{{end}}" value="{{if .Nalog.DatumZavrsetka}}{{.Nalog.DatumZavrsetka.Format "2006-01-02"}}{{end}}"
style="width:100%;"> style="width:100%;">
</div> </div>
<div>
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Garancija do</label>
<input type="date" name="garancija_do"
value="{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "2006-01-02"}}{{end}}"
style="width:100%;">
</div>
</div> </div>
</div> </div>
+46 -18
View File
@@ -81,14 +81,10 @@
<div class="sidebar-overlay" id="sidebar-overlay"></div> <div class="sidebar-overlay" id="sidebar-overlay"></div>
{{template "sidebar" .}} {{template "sidebar" .}}
<div class="glavni-sadrzaj"> <div class="glavni-sadrzaj" id="glavni-sadrzaj">
{{template "topbar" .}} {{template "topbar" .}}
<main class="sadrzaj">{{block "sadrzaj" .}}{{end}}</main> <main class="sadrzaj" id="sadrzaj">
</div>
</div>
<!-- flash poruka — prikazuje se kao toast u gornjem desnom uglu -->
{{if .Flash}} {{if .Flash}}
<div id="flash-toast" class="flash-toast flash-{{.Flash.Tip}}" role="alert"> <div id="flash-toast" class="flash-toast flash-{{.Flash.Tip}}" role="alert">
<span class="flash-ikona">{{if eq .Flash.Tip "uspeh"}}✓{{else}}!{{end}}</span> <span class="flash-ikona">{{if eq .Flash.Tip "uspeh"}}✓{{else}}!{{end}}</span>
@@ -106,6 +102,10 @@
})(); })();
</script> </script>
{{end}} {{end}}
{{block "sadrzaj" .}}{{end}}
</main>
</div>
</div>
<!-- alpine.js komponente (mora biti pre Alpine-a) --> <!-- alpine.js komponente (mora biti pre Alpine-a) -->
<script src="/static/js/ntech.js" defer></script> <script src="/static/js/ntech.js" defer></script>
@@ -115,22 +115,27 @@
<!-- htmx za komunikaciju sa serverom --> <!-- htmx za komunikaciju sa serverom -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.x.x/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.x.x/dist/htmx.min.js"></script>
<!-- sidebar logika --> <!-- sidebar logika — sidebar se NIKAD ne menja pri HTMX navigaciji, pa nema potrebe za swap logikom -->
<script> <script>
(function() { (function() {
if (window._ntechSidebarDodato) return;
window._ntechSidebarDodato = true;
function mobilni() { return window.innerWidth <= 768; } function mobilni() { return window.innerWidth <= 768; }
function inicijalizujSidebar() {
var sidebar = document.getElementById("sidebar"); var sidebar = document.getElementById("sidebar");
var hamburger = document.getElementById("hamburger"); var hamburger = document.getElementById("hamburger");
var overlay = document.getElementById("sidebar-overlay"); var overlay = document.getElementById("sidebar-overlay");
if (!sidebar || !hamburger || !overlay) return;
if (!mobilni() && localStorage.getItem("sidebar-skupljen") === "true") { // skupljeno stanje pri učitavanju stranice
if (sidebar && !mobilni() && localStorage.getItem("sidebar-skupljen") === "true") {
sidebar.classList.add("skupljen"); sidebar.classList.add("skupljen");
} }
document.documentElement.classList.remove('sidebar-init-skupljen'); document.documentElement.classList.remove('sidebar-init-skupljen');
if (typeof ntechInicijalizujPodmeni === 'function') ntechInicijalizujPodmeni();
// hamburger — sidebar element živi ceo vek stranice, listener se dodaje jednom
if (hamburger && sidebar && overlay) {
hamburger.addEventListener("click", function() { hamburger.addEventListener("click", function() {
if (mobilni()) { if (mobilni()) {
sidebar.classList.toggle("otvoren"); sidebar.classList.toggle("otvoren");
@@ -140,27 +145,50 @@
localStorage.setItem("sidebar-skupljen", sidebar.classList.contains("skupljen")); localStorage.setItem("sidebar-skupljen", sidebar.classList.contains("skupljen"));
} }
}); });
overlay.addEventListener("click", function() { overlay.addEventListener("click", function() {
sidebar.classList.remove("otvoren"); sidebar.classList.remove("otvoren");
overlay.classList.remove("aktivan"); overlay.classList.remove("aktivan");
}); });
} }
// resize listener se dodaje samo jednom — ne sme da se gomila po swap-ovima
if (!window._ntechResizeDodato) {
window._ntechResizeDodato = true;
window.addEventListener("resize", function() { window.addEventListener("resize", function() {
var sidebar = document.getElementById("sidebar"); if (!mobilni() && sidebar && overlay) {
var overlay = document.getElementById("sidebar-overlay");
if (sidebar && overlay && !mobilni()) {
sidebar.classList.remove("otvoren"); sidebar.classList.remove("otvoren");
overlay.classList.remove("aktivan"); overlay.classList.remove("aktivan");
} }
}); });
// aktivna stavka — ažurira se pri svakoj HTMX navigaciji
function azurirajAktivne() {
var putanja = window.location.pathname;
document.querySelectorAll('#sidebar .nav-stavka').forEach(function(el) {
el.classList.remove('aktivan');
});
document.querySelectorAll('#sidebar .nav-podmeni').forEach(function(el) {
el.classList.remove('otvoren');
var strelica = el.previousElementSibling && el.previousElementSibling.querySelector('.nav-strelica svg');
if (strelica) strelica.style.transform = 'rotate(0deg)';
});
document.querySelectorAll('#sidebar a.nav-stavka').forEach(function(el) {
var href = el.getAttribute('href');
if (!href) return;
if (putanja === href || putanja.startsWith(href + '/')) {
el.classList.add('aktivan');
var podmeni = el.closest('.nav-podmeni');
if (podmeni) {
podmeni.classList.add('otvoren');
var roditelj = podmeni.previousElementSibling;
if (roditelj) {
roditelj.classList.add('aktivan');
var str = roditelj.querySelector('.nav-strelica svg');
if (str) str.style.transform = 'rotate(180deg)';
}
}
}
});
} }
inicijalizujSidebar(); document.addEventListener('htmx:afterSwap', azurirajAktivne);
})(); })();
</script> </script>