From a99920d102b193e1f1025bdb9e72f481f0cac192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Mon, 8 Jun 2026 19:29:17 +0200 Subject: [PATCH] Popravka sidebara: kolaps, podmeni i HTMX navigacija --- cmd/ntech/main.go | 2 + internal/db/repository.go | 15 +- internal/db/sqlite/artikal.go | 18 +- internal/db/sqlite/klijent.go | 38 +-- internal/db/sqlite/magacin.go | 102 +++++++++ internal/db/sqlite/migracije.go | 10 +- internal/db/sqlite/prodaja.go | 242 ++++++++++++++------ internal/db/sqlite/servis.go | 47 ++-- internal/db/sqlite/servisni_delovi.go | 159 +++++++++++++ internal/handler/handler.go | 12 +- internal/handler/klijent.go | 16 +- internal/handler/podesavanja.go | 3 + internal/handler/prodaja.go | 33 ++- internal/handler/servis.go | 158 ++++++++++++- internal/model/artikal.go | 12 + internal/model/klijent.go | 15 +- internal/model/magacin.go | 27 +++ internal/model/prodaja.go | 18 +- internal/model/servis.go | 31 +++ migrations/035_pos_faza1.sql | 52 +++++ migrations/036_nabavna_cena_artikli.sql | 4 + web/static/css/main.css | 20 +- web/static/js/ntech.js | 54 ++++- web/templates/komponente/sidebar.html | 50 ++-- web/templates/komponente/topbar.html | 29 ++- web/templates/stranice/izvestaji.html | 17 +- web/templates/stranice/klijent_forma.html | 107 ++++++--- web/templates/stranice/prodaja_detalji.html | 187 +++++++-------- web/templates/stranice/prodaja_forma.html | 49 ++-- web/templates/stranice/servis_detalji.html | 91 +++++++- web/templates/stranice/servis_forma.html | 55 +++-- web/templates/teme/podrazumevana/base.html | 112 +++++---- 32 files changed, 1385 insertions(+), 400 deletions(-) create mode 100644 internal/db/sqlite/magacin.go create mode 100644 internal/db/sqlite/servisni_delovi.go create mode 100644 internal/model/magacin.go create mode 100644 migrations/035_pos_faza1.sql create mode 100644 migrations/036_nabavna_cena_artikli.sql diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 66a9a5e..d6061d2 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -191,6 +191,8 @@ func main() { r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga) r.Post("/servis/obrisi/{id}", h.ObrisiNalog) 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("/prodaja", h.Prodaja) r.Get("/prodaja/nova", h.NovaProdaja) diff --git a/internal/db/repository.go b/internal/db/repository.go index 4e34793..8c2fad0 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -71,11 +71,24 @@ type ProdajaRepository interface { Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error) DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, 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 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 type KorisniciRepository interface { Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error) diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index ed995fb..4466451 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -25,7 +25,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod SELECT a.id, a.kategorija_id, a.naziv, a.opis, 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 FROM artikli a 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( &a.ID, &kategorijaID, &a.Naziv, &a.Opis, &a.Kolicina, &a.KolicinMin, &a.Lokacija, - &a.ProdajnaCena, &a.Napomena, &a.DatumUnosa, + &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa, &a.KategorijaNaziv, ) if err != nil { @@ -89,11 +89,11 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, err := r.db.QueryRowContext(ctx, ` 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( &a.ID, &kategorijaID, &a.Naziv, &a.Opis, &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 { 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, ` INSERT INTO artikli (kategorija_id, naziv, opis, kolicina, kolicina_min, lokacija, - prodajna_cena, napomena) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + nabavna_cena, prodajna_cena, pdv_stopa, napomena) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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 { 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 kategorija_id = ?, naziv = ?, opis = ?, kolicina = ?, kolicina_min = ?, lokacija = ?, - prodajna_cena = ?, napomena = ? + nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, napomena = ? WHERE id = ?`, a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin, a.Lokacija, - a.ProdajnaCena, a.Napomena, a.ID, + a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena, a.ID, ) if err != nil { return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err) diff --git a/internal/db/sqlite/klijent.go b/internal/db/sqlite/klijent.go index 06ee46d..3dc8c2d 100644 --- a/internal/db/sqlite/klijent.go +++ b/internal/db/sqlite/klijent.go @@ -21,7 +21,7 @@ func NoviKlijentRepo(db *sql.DB) *KlijentRepo { // 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) { 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 WHERE 1=1` @@ -44,15 +44,16 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije var rezultat []model.Klijent for redovi.Next() { 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( - &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 { return nil, fmt.Errorf("ntech: KlijentRepo.Lista: scan: %w", err) } k.Ime = ime.String k.Prezime = prezime.String + k.JMBG = jmbg.String k.NazivFirme = nazivFirme.String k.PIB = pib.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 func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) { 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, ` - 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( - &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 { 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.Prezime = prezime.String + k.JMBG = jmbg.String k.NazivFirme = nazivFirme.String k.PIB = pib.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 func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) { + if k.Tip == "" { + k.Tip = "fizicko" + } rezultat, err := r.db.ExecContext(ctx, ` - INSERT INTO klijenti (ime, prezime, naziv_firme, pib, telefon, email, napomena) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - nullString(k.Ime), nullString(k.Prezime), nullString(k.NazivFirme), - nullString(k.PIB), nullString(k.Telefon), nullString(k.Email), nullString(k.Napomena), + INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG), + nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon), + nullString(k.Email), nullString(k.Napomena), ) if err != nil { 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 func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error { + if k.Tip == "" { + k.Tip = "fizicko" + } _, err := r.db.ExecContext(ctx, ` UPDATE klijenti SET - ime = ?, prezime = ?, naziv_firme = ?, pib = ?, telefon = ?, email = ?, napomena = ? + tip = ?, ime = ?, prezime = ?, jmbg = ?, naziv_firme = ?, + pib = ?, telefon = ?, email = ?, napomena = ? WHERE id = ?`, - nullString(k.Ime), nullString(k.Prezime), nullString(k.NazivFirme), - nullString(k.PIB), nullString(k.Telefon), nullString(k.Email), nullString(k.Napomena), - k.ID, + k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG), + nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon), + nullString(k.Email), nullString(k.Napomena), k.ID, ) if err != nil { return fmt.Errorf("ntech: KlijentRepo.Izmeni: %w", err) diff --git a/internal/db/sqlite/magacin.go b/internal/db/sqlite/magacin.go new file mode 100644 index 0000000..b847691 --- /dev/null +++ b/internal/db/sqlite/magacin.go @@ -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 +} diff --git a/internal/db/sqlite/migracije.go b/internal/db/sqlite/migracije.go index e7b352f..b4887fd 100644 --- a/internal/db/sqlite/migracije.go +++ b/internal/db/sqlite/migracije.go @@ -4,8 +4,10 @@ import ( "database/sql" "fmt" "io/fs" + "log" "path" "sort" + "strings" _ "modernc.org/sqlite" ) @@ -82,7 +84,13 @@ func PokreniMigracije(db *sql.DB, fsys fs.FS) error { // izvršavamo SQL if _, err := db.Exec(string(sadrzaj)); err != nil { - return fmt.Errorf("ntech: PokreniMigracije: izvršavanje %s: %w", naziv, err) + // "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) + } } // upisujemo u tabelu migracija da je izvršena diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index 5bff0a7..7a1215b 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -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) { upit := ` 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 FROM prodajni_nalozi pn 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 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, ) 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 func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error) { 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) var n model.ProdajniNalog 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 { 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.Napomena = napomena.String + n.RazlogStorniranja = razlogStorniranja.String 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) { redovi, err := r.db.QueryContext(ctx, ` 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 JOIN artikli a ON a.id = sp.artikal_id WHERE sp.nalog_id = ? @@ -131,7 +138,9 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model var s model.StavkaProdajeSaArtiklom err := redovi.Scan( &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 { 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. -// 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. -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) if err != nil { return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: begin tx: %w", err) } defer tx.Rollback() - // provera i smanjenje stanja za svaku stavku - 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 + // insert zaglavlja naloga pre stavki da bismo imali nalogID za magacin rezultat, err := tx.ExecContext(ctx, ` - INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, datum) - VALUES (?, ?, ?, ?, ?)`, - nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.Datum, + INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum) + VALUES (?, ?, ?, ?, ?, ?)`, + nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum, ) if err != nil { 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) } - // insert stavki + // provera stanja, smanjenje i insert stavki 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 - _, err := tx.ExecContext(ctx, ` - INSERT INTO stavke_prodaje (nalog_id, artikal_id, kolicina, cena_po_komadu, ukupno) - VALUES (?, ?, ?, ?, ?)`, + _, err = tx.ExecContext(ctx, ` + INSERT INTO stavke_prodaje + (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, + s.PdvStopa, pdvIznos, cenaBezPdv, ) if err != nil { 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 { @@ -211,6 +237,83 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk 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) func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error { tx, err := r.db.BeginTx(ctx, nil) @@ -219,32 +322,43 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error { } defer tx.Rollback() - // vraćanje stanja u magacin - redovi, err := tx.QueryContext(ctx, - "SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) + // 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: dohvati stavke: %w", err) + return fmt.Errorf("ntech: ProdajaRepo.Obrisi: provera: %w", err) } - type povrat struct{ artikalID int64; kolicina int } - var stavke []povrat - for redovi.Next() { - var p povrat - if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil { - redovi.Close() - return fmt.Errorf("ntech: ProdajaRepo.Obrisi: scan stavke: %w", err) - } - stavke = append(stavke, p) - } - redovi.Close() - - for _, p := range stavke { - _, err := tx.ExecContext(ctx, - "UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?", - p.kolicina, p.artikalID, - ) + if !stornirano { + redovi, err := tx.QueryContext(ctx, + "SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) if err != nil { - return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err) + return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stavke: %w", err) + } + + type povrat struct { + artikalID int64 + kolicina int + } + var stavke []povrat + for redovi.Next() { + var p povrat + if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil { + redovi.Close() + return fmt.Errorf("ntech: ProdajaRepo.Obrisi: scan stavke: %w", err) + } + stavke = append(stavke, p) + } + redovi.Close() + + for _, p := range stavke { + _, err := tx.ExecContext(ctx, + "UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?", + p.kolicina, p.artikalID, + ) + if err != nil { + return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err) + } } } diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index 56cb3fe..9806d04 100644 --- a/internal/db/sqlite/servis.go +++ b/internal/db/sqlite/servis.go @@ -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) { upit := ` 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.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 FROM servisni_nalozi sn 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) { red := r.db.QueryRowContext(ctx, ` 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, - avans, napomena, datum_prijema, datum_zavrsetka + avans, napomena, garancija_do, datum_prijema, datum_zavrsetka FROM servisni_nalozi WHERE id = ?`, id) 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) { rezultat, err := r.db.ExecContext(ctx, ` INSERT INTO servisni_nalozi - (klijent_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, - status, cena_od, cena_do, cena_konacna, avans, napomena, datum_zavrsetka) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - nullInt64(n.KlijentID), n.BrojNaloga, n.Uredjaj, nullString(n.SerijskiBroj), - n.OpisKvara, n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), - nullFloat64(n.CenaKonacna), nullFloat64(n.Avans), nullString(n.Napomena), - nullTime(n.DatumZavrsetka), + (klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, + status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj, + nullString(n.SerijskiBroj), n.OpisKvara, n.Status, + nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna), + nullFloat64(n.Avans), nullString(n.Napomena), + nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), ) if err != nil { 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 { _, err := r.db.ExecContext(ctx, ` 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 = ?, - avans = ?, napomena = ?, datum_zavrsetka = ? + avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ? 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), - nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.DatumZavrsetka), + nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka), n.ID, ) 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 — // klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error { - var klijentID sql.NullInt64 + var klijentID, tehnicarID sql.NullInt64 var serijskiBroj, napomena sql.NullString var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64 - var datumZavrsetka sql.NullTime + var garancijaDo, datumZavrsetka sql.NullTime 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, - &avans, &napomena, &n.DatumPrijema, &datumZavrsetka, + &avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka, } if klijentNaziv != nil { @@ -180,6 +181,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st v := klijentID.Int64 n.KlijentID = &v } + if tehnicarID.Valid { + v := tehnicarID.Int64 + n.TehnicarID = &v + } n.SerijskiBroj = serijskiBroj.String n.Napomena = napomena.String if cenaOd.Valid { @@ -198,6 +203,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st v := avans.Float64 n.Avans = &v } + if garancijaDo.Valid { + v := garancijaDo.Time + n.GarancijaDo = &v + } if datumZavrsetka.Valid { v := datumZavrsetka.Time n.DatumZavrsetka = &v diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go new file mode 100644 index 0000000..280531b --- /dev/null +++ b/internal/db/sqlite/servisni_delovi.go @@ -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 +} + diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 5d9dac1..3fd10eb 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -21,7 +21,9 @@ type Handler struct { DobavljaciRepo db.DobavljacRepository NabavkeRepo db.NabavkaRepository KlijentiRepo db.KlijentRepository - ServisRepo db.ServisRepository + ServisRepo db.ServisRepository + ServisniDeloviRepo db.ServisniDeloviRepository + MagacinskePromeneRepo db.MagacinskePromeneRepository ProdajaRepo db.ProdajaRepository KorisniciRepo db.KorisniciRepository SesijeRepo db.SesijeRepository @@ -43,8 +45,10 @@ func Novi(baza *sql.DB) *Handler { DobavljaciRepo: sqlite.NoviDobavljacRepo(baza), NabavkeRepo: sqlite.NoviNabavkaRepo(baza), KlijentiRepo: sqlite.NoviKlijentRepo(baza), - ServisRepo: sqlite.NoviServisRepo(baza), - ProdajaRepo: sqlite.NoviProdajaRepo(baza), + ServisRepo: sqlite.NoviServisRepo(baza), + ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza), + MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza), + ProdajaRepo: sqlite.NoviProdajaRepo(baza), KorisniciRepo: sqlite.NoviKorisniciRepo(baza), SesijeRepo: sqlite.NoviSesijeRepo(baza), PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza), @@ -63,6 +67,8 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB) h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB) h.ServisRepo = sqlite.NoviServisRepo(novaDB) + h.ServisniDeloviRepo = sqlite.NoviServisniDeloviRepo(novaDB) + h.MagacinskePromeneRepo = sqlite.NoviMagacinskePromeneRepo(novaDB) h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB) h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB) h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB) diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index d841289..ec2d88b 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -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 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")) nazivFirme := strings.TrimSpace(r.FormValue("naziv_firme")) - if ime == "" && nazivFirme == "" { - return model.Klijent{}, "Mora biti uneto ime i prezime ili naziv firme." + if tip == "fizicko" && ime == "" { + 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")) 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{ + Tip: tip, Ime: ime, Prezime: strings.TrimSpace(r.FormValue("prezime")), + JMBG: strings.TrimSpace(r.FormValue("jmbg")), NazivFirme: nazivFirme, PIB: strings.TrimSpace(r.FormValue("pib")), Telefon: strings.TrimSpace(r.FormValue("telefon")), diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 1c3c585..06cecda 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -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) return } + podaci.Stranica = "podesavanja-opste" 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) return } + podaci.Stranica = "podesavanja-izgled" 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) return } + podaci.Stranica = "podesavanja-sistem" h.renderujTemplate(w, "podesavanja_sistem", podaci) } diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 8c82075..91a2a20 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -57,17 +57,18 @@ type PodaciStampeProdaje struct { 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 { type stavka struct { ID int64 `json:"id"` Naziv string `json:"naziv"` Cena float64 `json:"cena"` + PdvStopa float64 `json:"pdv_stopa"` Kolicina int `json:"kolicina"` } lista := make([]stavka, 0, len(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) return template.JS(b) @@ -185,7 +186,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { } 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 { var errStanje *appdb.ErrNedovoljnoKolicine 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.NacinPlacanja = r.FormValue("nacin_placanja") + if nalog.NacinPlacanja != "gotovina" && nalog.NacinPlacanja != "kartica" && nalog.NacinPlacanja != "prenos" { + nalog.NacinPlacanja = "gotovina" + } artikalIDovi := r.Form["artikal_id[]"] kolicine := r.Form["kolicina[]"] cene := r.Form["cena_po_komadu[]"] + pdvStope := r.Form["pdv_stopa[]"] if len(artikalIDovi) == 0 { 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." } + var pdvStopa float64 + if i < len(pdvStope) { + pdvStopa, _ = strconv.ParseFloat(strings.TrimSpace(pdvStope[i]), 64) + } + stavke = append(stavke, model.StavkaProdaje{ ArtikalID: artikalID, Kolicina: kolicina, CenaPoKomadu: cena, + PdvStopa: pdvStopa, }) } 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) { k := middleware.KorisnikIzKonteksta(r.Context()) 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) return } - if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil { - http.Error(w, "Greška pri storniranju naloga", http.StatusInternalServerError) + if err := r.ParseForm(); err != nil { + 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 } 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 diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 89fc4ca..efb888e 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -7,6 +7,7 @@ import ( "strings" "time" + appdbPkg "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" "ntech/internal/model" @@ -30,6 +31,7 @@ type PodaciFormeNaloga struct { model.PodaciStranice Nalog model.ServisniNalog Klijenti []model.Klijent + Tehnicari []model.Korisnik SviStatusi []string Greska string Izmena bool @@ -38,9 +40,12 @@ type PodaciFormeNaloga struct { // PodaciDetaljiNaloga su podaci za pregled jednog servisnog naloga type PodaciDetaljiNaloga struct { model.PodaciStranice - Nalog model.ServisniNalog - KlijentNaziv string - Sacuvano bool + Nalog model.ServisniNalog + KlijentNaziv string + TehnicarNaziv string + ServisniDelovi []model.ServisniDeoSaArtiklom + Artikli []model.ArtikalSaKategorijom + Sacuvano bool } // Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa @@ -96,6 +101,12 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) { 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.Stranica = "servis" ps.NaslovStranice = "Novi nalog" @@ -103,6 +114,7 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Nalog: model.ServisniNalog{BrojNaloga: brojNaloga, Status: model.StatusPrimljeno}, Klijenti: klijenti, + Tehnicari: tehnicari, SviStatusi: model.SviStatusi, Izmena: false, }) @@ -124,6 +136,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) { if greska != "" { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") + tehnicari, _ := h.KorisniciRepo.Lista(r.Context()) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "servis" ps.NaslovStranice = "Novi nalog" @@ -131,6 +144,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Nalog: nalog, Klijenti: klijenti, + Tehnicari: tehnicari, SviStatusi: model.SviStatusi, Greska: greska, 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) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") + tehnicari, _ := h.KorisniciRepo.Lista(r.Context()) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "servis" ps.NaslovStranice = "Novi nalog" @@ -150,6 +165,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Nalog: nalog, Klijenti: klijenti, + Tehnicari: tehnicari, SviStatusi: model.SviStatusi, Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.", Izmena: false, @@ -186,6 +202,12 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) { 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.Stranica = "servis" ps.NaslovStranice = "Izmeni nalog" @@ -193,6 +215,7 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Nalog: *nalog, Klijenti: klijenti, + Tehnicari: tehnicari, SviStatusi: model.SviStatusi, Izmena: true, }) @@ -220,6 +243,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) { if greska != "" { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") + tehnicari, _ := h.KorisniciRepo.Lista(r.Context()) nalog.ID = id ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "servis" @@ -228,6 +252,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Nalog: nalog, Klijenti: klijenti, + Tehnicari: tehnicari, SviStatusi: model.SviStatusi, Greska: greska, 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) podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") + tehnicari, _ := h.KorisniciRepo.Lista(r.Context()) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "servis" ps.NaslovStranice = "Izmeni nalog" @@ -247,6 +273,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Nalog: nalog, Klijenti: klijenti, + Tehnicari: tehnicari, SviStatusi: model.SviStatusi, Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.", 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) } -// 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) { id, err := parseID(chi.URLParam(r, "id")) if err != nil { @@ -302,14 +329,29 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { if nalog.KlijentID != nil { klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID) if err == nil { - if klijent.NazivFirme != "" { - klijentNaziv = klijent.NazivFirme - } else { - klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime) - } + klijentNaziv = klijent.PunoIme() } } + 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.Stranica = "servis" ps.NaslovStranice = "Detalji naloga" @@ -317,12 +359,96 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Nalog: *nalog, KlijentNaziv: klijentNaziv, + TehnicarNaziv: tehnicarNaziv, + ServisniDelovi: delovi, + Artikli: artikli, Sacuvano: r.URL.Query().Get("sacuvano") == "1", } 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 func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) { 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 nalog.CenaOd = parseOpcionuCenu(r.FormValue("cena_od")) 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, "" } diff --git a/internal/model/artikal.go b/internal/model/artikal.go index b7f16f7..432c9b0 100644 --- a/internal/model/artikal.go +++ b/internal/model/artikal.go @@ -11,11 +11,23 @@ type Artikal struct { Kolicina int KolicinMin int Lokacija string + NabavnaCena float64 ProdajnaCena float64 + PdvStopa float64 Napomena string 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 type Kategorija struct { ID int64 diff --git a/internal/model/klijent.go b/internal/model/klijent.go index c431052..7f357f0 100644 --- a/internal/model/klijent.go +++ b/internal/model/klijent.go @@ -1,12 +1,17 @@ package model -import "time" +import ( + "strings" + "time" +) // Klijent predstavlja jednog klijenta — fizičko lice ili firmu type Klijent struct { ID int64 + Tip string Ime string Prezime string + JMBG string NazivFirme string PIB string Telefon string @@ -14,3 +19,11 @@ type Klijent struct { Napomena string 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) +} diff --git a/internal/model/magacin.go b/internal/model/magacin.go new file mode 100644 index 0000000..ddc421e --- /dev/null +++ b/internal/model/magacin.go @@ -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 +} diff --git a/internal/model/prodaja.go b/internal/model/prodaja.go index cf25a08..e9a704d 100644 --- a/internal/model/prodaja.go +++ b/internal/model/prodaja.go @@ -4,12 +4,15 @@ import "time" // ProdajniNalog predstavlja zaglavlje jedne prodaje type ProdajniNalog struct { - ID int64 - KlijentID *int64 - BrojNaloga string - Napomena string - Ukupno float64 - Datum time.Time + ID int64 + KlijentID *int64 + BrojNaloga string + Napomena string + Ukupno float64 + NacinPlacanja string + Stornirano bool + RazlogStorniranja string + Datum time.Time } // StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje @@ -20,6 +23,9 @@ type StavkaProdaje struct { Kolicina int CenaPoKomadu float64 Ukupno float64 + PdvStopa float64 + PdvIznos float64 + CenaBezPdv float64 } // ProdajniNalogSaDetaljem je nalog sa nazivom klijenta — za prikaz u listi diff --git a/internal/model/servis.go b/internal/model/servis.go index 07ec704..1cdb607 100644 --- a/internal/model/servis.go +++ b/internal/model/servis.go @@ -29,6 +29,7 @@ var SviStatusi = []string{ type ServisniNalog struct { ID int64 KlijentID *int64 + TehnicarID *int64 BrojNaloga string Uredjaj string SerijskiBroj string @@ -39,10 +40,32 @@ type ServisniNalog struct { CenaKonacna *float64 Avans *float64 Napomena string + GarancijaDo *time.Time DatumPrijema 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 type ServisniNalogSaKlijentom struct { ServisniNalog @@ -57,6 +80,14 @@ func (n ServisniNalog) KlijentIDVrednost() int64 { 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 func (n ServisniNalog) CenaOdStr() string { if n.CenaOd == nil { diff --git a/migrations/035_pos_faza1.sql b/migrations/035_pos_faza1.sql new file mode 100644 index 0000000..dbd9361 --- /dev/null +++ b/migrations/035_pos_faza1.sql @@ -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 +); diff --git a/migrations/036_nabavna_cena_artikli.sql b/migrations/036_nabavna_cena_artikli.sql new file mode 100644 index 0000000..a84001e --- /dev/null +++ b/migrations/036_nabavna_cena_artikli.sql @@ -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; diff --git a/web/static/css/main.css b/web/static/css/main.css index ff38ef7..5cfe4d6 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -208,8 +208,22 @@ body { /* accordion podmeni u sidebaru */ .nav-podmeni { 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 { padding-left: 48px !important; } @@ -256,8 +270,10 @@ body { 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; + pointer-events: none; } /* dno sidebara */ diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index 6aef228..ec370e8 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -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', () => { // sidebar — podmeni "Moj profil" @@ -82,7 +122,7 @@ document.addEventListener('alpine:init', () => { // forma za prodaju Alpine.data('prodajaForma', () => ({ - stavke: [{artikal_id: '', kolicina: 1, cena: 0}], + stavke: [{artikal_id: '', kolicina: 1, cena: 0, pdv_stopa: 20}], artikliOpcije: [], isMobile: false, init() { @@ -93,14 +133,17 @@ document.addEventListener('alpine:init', () => { }) }, dodajStavku() { - this.stavke.push({artikal_id: '', kolicina: 1, cena: 0}) + this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, pdv_stopa: 20}) }, ukloniStavku(i) { if (this.stavke.length > 1) this.stavke.splice(i, 1) }, popuniCenu(stavka) { 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) { 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() diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index 0a3ff78..e51b6e6 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -24,7 +24,7 @@ -