9 Commits
Author SHA1 Message Date
Dasko 87699ff5ff Merge grane develop: bezbednosne popravke, refaktor i README ažuriranja 2026-07-04 01:36:02 +02:00
Dasko 48f9ba8435 README: ažurirani opisi funkcionalnosti i nove sekcije
- ispravljena Go verzija zahteva (1.24 -> 1.26, usklađeno sa go.mod)
- dodate BE_ENABLED/BE_PORT env varijable
- nova sekcija "Kako radi"/"How It Works" — arhitektura, tok zahteva,
  javne stranice za klijente, štampani dokumenti
- nove sekcije Health Check, Testing, Security Notes, License
- fiskalizacija prebačena iz "U toku" u "Implementirano" — bila je
  zastarela (opisivala kao "u planu" nešto što je u potpunosti
  implementirano i integrisano); "U toku" sada tačnije opisuje da
  nedostaje samo test na pravom sertifikovanom uređaju
- eksplicitno pomenut QR kod + mobilno praćenje statusa servisa
  (ranije nejasno "putem jedinstvenog linka"), ispravljeno pogrešno
  pominjanje obaveštenja koja nisu implementirana
- dodat EAN/barkod po artiklu i skeniranje barkoda u prodaji
2026-07-04 01:35:57 +02:00
Dasko fba3cb15a6 Refaktor #23: zajedničko zaglavlje servisnih štampanih dokumenata
Izdvojen partial web/templates/komponente/servis_zaglavlje.html sa dva
{{define}} bloka (zaglavljeFirma, zaglavljeKlijent) — koriste ih revers,
predračun, otpremnica (oba bloka) i eskalacioni list (samo firma; klijent
blok tamo ima drugačiji markup/stil pa nije spojen).

kes.go: dodata standaloneExtraFajlovi mapa da standalone šabloni mogu da
uključe dodatne partial fajlove uz svoj glavni (i u produkcionom kešu i u
dev lazy-parse putanji).

Vizuelno provereno u browseru (sva 4 dokumenta, servis nalog SN-2606-007) —
identičan izgled kao pre refaktora, ispravan redosled kolona po dokumentu.
2026-07-04 00:58:15 +02:00
Dasko e99dd50273 Prava ispravka: usluge/troskovi.datum_unosa promenjen na DATETIME u šemi
Umesto ručnog parsiranja (prethodni commit), migracija 103 rekreira obe
tabele sa DATETIME kolonom (kao svuda drugde u šemi) — SQLite ne podržava
ALTER COLUMN, pa je urađeno kreiraj-novu/prebaci-podatke/obriši-staru/
preimenuj. Fizički format podataka se ne menja (SQLite nema pravi numerički
afinitet za DATETIME nad ovakvim stringom), menja se samo deklarisani tip
kolone koji driver čita da odluči string vs time.Time pri Scan-u.

Uklonjen parseDatumUnosa workaround iz utils.go — skenirajUslugu/skenirajTrosak
sada rade identično kao svi ostali skenirajX helperi (direktan Scan u time.Time).

Potvrđeno: migracija testirana na praznoj i na postojećoj bazi sa podacima
(6 usluga, ID-jevi i datumi netaknuti), /usluge i /troskovi vraćaju 200.
2026-07-04 00:09:32 +02:00
Dasko 295fe0b9ff Ispravka: Usluge/Troškovi se nisu učitavali (regresija iz DatumUnosa time.Time izmene)
usluge.datum_unosa i troskovi.datum_unosa su TEXT kolone (ne DATETIME) —
modernc.org/sqlite driver za njih vraća string, ne time.Time, pa je Scan
direktno u *time.Time pucao sa "unsupported Scan ... string into *time.Time".

Dodat parseDatumUnosa helper (ručni time.Parse), koriste ga
skenirajUslugu/skenirajTrosak umesto oslanjanja na driver auto-konverziju.
2026-07-03 23:42:30 +02:00
Dasko 45ed942c56 Merge grane fix/code-review-nalazi: bezbednosne i integritetne popravke iz pregleda koda 2026-07-03 23:31:48 +02:00
Dasko d9f337617b Nizak prioritet iz pregleda koda: indeksi, greške, sitne popravke
- migracija 102: indeksi na FK kolonama (stavke_prodaje, servisni_delovi,
  servisni_potrazivani_delovi, magacinske_promene, stavke_nabavke, pdv_kir,
  pdv_kpr) — potvrđeno kreiranje i idempotentnost
- BrojKriticnihZaliha/KriticneZalihe filtriraju samo proizvode i aktivne
  artikle, usklađeno sa ArtikalRepo.Lista(SamoKriticni)
- url.QueryEscape na query string u magacin.go/klijent.go (paginacija)
- TestFiskalizacije/BeStatus vraćaju generičku poruku, detalj u slog
- SetFlash loguje grešku umesto tihog ignorisanja
- os.WriteFile/os.MkdirAll u main.go proveravaju grešku
- dummyHash generisanje panikuje na startu ako bcrypt ikad zakaže
  (umesto tihog sloma anti-enumeracije)
- PrihvatiPredlozene proverava redovi.Err()
- /healthz endpoint (DB ping)
- hx-boost na "Osveži" linkovima (blokirane_ip, fiskal_pazar)
- MaxBytesReader (4KB) na javne /status/{token}/* POST rute
- komentar u auth.go zašto postoje dva flash mehanizma (namerno, ne spojeno)

Provereno: jePrivatnaAdresa ne radi DNS lookup (nema net.LookupIP u repou),
pa DNS-rebinding nalaz iz pregleda nije primenljiv na trenutni kod.
2026-07-03 23:31:00 +02:00
Dasko 127cebea3c Srednji prioritet iz pregleda koda: konzistentnost, refaktor, sitne popravke
- konstantno-vremensko poređenje CSRF tokena i kartica-PIN-a
- CSRF grupa proširena na /prijava, /prijava/totp, /setup
- router-level dozvola na svim /admin/podesavanja/* i modul("fiskalizacija")
  na fiskal.pazar rutama
- KlijentRepo.Pronadji razlikuje sql.ErrNoRows od stvarnih DB grešaka
- izdvojen zajednički vratiStavkeNaStanje umesto 4 duplirana bloka
  (ProdajaRepo/ServisRepo Storno/Obrisi)
- datumi u KIR/KPR sada se upisuju/porede kao "2006-01-02", usklađeno sa KPO
- ArtikalRepo.DohvatiVise (batch dohvat) — ukida N+1 upite u SacuvajNabavku
- KategorijaRepo.Obrisi umesto direktnog SQL-a u handleru, sa
  ErrKategorijaUUpotrebi porukom
- KolicinMin -> KolicinaMin (tipfeler), Trosak/Usluga.DatumUnosa string -> time.Time
- dedup OznakaPoreskogBroja (KIR/KPR)
- inicijali() template funkcija (rune, ne bajtovi) za avatar inicijale
2026-07-03 23:21:29 +02:00
Dasko 49296e4315 Bezbednosne i integritetne popravke iz pregleda koda
- foreign_keys pragma po konekciji (DSN _pragma) — ranije aktivna na
  samo delu konekcija iz pula, rizik siročadi u bazi pri brisanju
- busy_timeout + _txlock=immediate za konkurentan pristup SQLite bazi
- ispravljen DnevniPrometMaloprodaje (DATE() -> substr) — dnevni pazar
  za KIR/KPO je bio uvek nula
- rate-limit (isti IP-bruteforce brojač) dodat na TOTP/2FA verifikaciju
- ispravljen nil-pointer u DodajDeloNalogu + uklonjeni debug logovi
- izvuciIP prihvata X-Real-IP/X-Forwarded-For samo sa poverljive
  (loopback/privatne) adrese — sprečava zaobilaženje bruteforce brave
- ispravljen naziv dozvole podesavanja.izmena -> podesavanja.izmeni
- brisanje artikala/kategorija prebačeno sa GET na POST (CSRF zaštita)
2026-07-03 23:00:44 +02:00
52 changed files with 710 additions and 459 deletions
+68 -3
View File
@@ -23,6 +23,22 @@ The goal is simple: everything the repair shop needs to track is located in one
--- ---
## How It Works
**Server-rendered, no build step.** Pages are rendered on the server with Go's `html/template` (auto-escaping, no client-side templating engine). [HTMX](https://htmx.org) swaps page fragments over plain HTTP for SPA-like navigation, and [Alpine.js](https://alpinejs.dev) handles small bits of client-side interactivity (live totals, dynamic form rows, autocomplete). There is no `npm`/webpack build — the browser gets plain HTML/CSS/JS, and a single page load is enough to use the whole app.
**Single binary, no runtime dependencies.** Templates, static assets (CSS/JS/images) and SQL migrations are embedded into the compiled binary with `go:embed`. Deploying is copying one file (or running one Docker image) — there's no separate asset build, no template files to ship alongside the executable. In development the same code reads straight from disk instead, so template/CSS/JS edits are visible on refresh without a rebuild.
**Request flow.** `chi` routes each request through a middleware chain — security headers → CSRF (double-submit cookie) → session lookup → role/permission check — before it reaches a handler. Permission checks run at the router level (so a route can't accidentally ship unprotected) and again inside the handler as defense in depth. Handlers talk to the database through a repository layer (plain SQL, no ORM) and pass plain Go structs to templates.
**Database.** SQLite via a pure-Go driver (`modernc.org/sqlite`, no CGO) is the default and only dependency — a single file, no separate database server to run or back up. Every startup applies any new SQL migration files in order and records them in a `migracije` table, so upgrades are just "replace the binary and restart." An optional PostgreSQL backend (via `pgx/v5`) is planned for multi-user setups.
**Client-facing public pages.** Two flows don't require a login, only a unique unguessable token in the URL: the service-status page (a client can check repair progress and get a QR code straight from their receipt) and the parts/service proposal approval page (a client accepts or rejects an estimate with a comment). Both are capability-based — whoever has the link has access to that one order, nothing else.
**Printable documents.** Work orders, pre-invoices, dispatch notes, return slips, and device labels are separate, self-contained HTML pages styled for A4 printing (`@media print`), each with a "Print" button that opens the browser's native print dialog — no PDF library, no headless-browser rendering step. Documents that don't fit one page paginate themselves client-side (measuring content height and inserting page breaks with running page numbers).
---
## Features ## Features
### Implemented ### Implemented
@@ -39,12 +55,13 @@ The goal is simple: everything the repair shop needs to track is located in one
- Login attempt logging — history by user, IP, reason, date - Login attempt logging — history by user, IP, reason, date
- Users and roles — admin panel, user management - Users and roles — admin panel, user management
- Inventory — items, categories, filtering, critical stock levels, per-item stock card, supplier links, item transfers - Inventory — items, categories, filtering, critical stock levels, per-item stock card, supplier links, item transfers
- Barcode (EAN) per item — searchable by barcode in inventory; in the sales screen, scanning a barcode (any USB/Bluetooth scanner that types + Enter) looks the item up and adds it to the order automatically
- Service orders: - Service orders:
- Intake form, status bar, archive - Intake form, status bar, archive
- Diagnostic workflow — fault description, technician notes, work done, diagnostic fee - Diagnostic workflow — fault description, technician notes, work done, diagnostic fee
- Parts and services — used items deducted from stock; suggested items (proposal to client) - Parts and services — used items deducted from stock; suggested items (proposal to client)
- Client proposal approval — client receives a public link (QR code) to accept or reject a parts/service proposal with a comment - Client proposal approval — client receives a public link (QR code) to accept or reject a parts/service proposal with a comment
- Public status page — client can check order status and receive notifications via a unique link - Public status tracking via QR code — the device label and every printed document carry a QR code the client scans with their phone; it opens a mobile-optimized page (no login, no app) showing the current repair status, which they can revisit any time by the same link/code
- Documents — work order, pre-invoice (estimate), dispatch note, return slip, device label (QR + Code128 barcode) - Documents — work order, pre-invoice (estimate), dispatch note, return slip, device label (QR + Code128 barcode)
- Pickup with payment — tracks payment method and advance amount - Pickup with payment — tracks payment method and advance amount
- Guarantee period, expected completion date, technician assignment, client notes - Guarantee period, expected completion date, technician assignment, client notes
@@ -58,6 +75,7 @@ The goal is simple: everything the repair shop needs to track is located in one
- VAT records (KIR/KPR) — books of issued and received invoices, auto-filled from sales and procurement - VAT records (KIR/KPR) — books of issued and received invoices, auto-filled from sales and procurement
- VAT calculation per period + mapping to the PP-PDV form; imports (customs declaration) tracked in fields 006/106 - VAT calculation per period + mapping to the PP-PDV form; imports (customs declaration) tracked in fields 006/106
- VAT rate code list - VAT rate code list
- **Fiscalization (ESIR/L-PFR)** — full Go client for the Teron fiscal device API: connection test, invoice issuing (sale/service, including advances and refunds on cancellation), daily till summary, end-of-day closure, PDF fiscal reports, QR-code invoice verification (public `/v/` page), automatic retry on failed fiscalization with a visible error state, and a card-emulator status/reset panel (talks to the signing device over TCP). Currently verified against the Teron mock server (`Fisk/`); not yet tested against real certified hardware.
- Clients and suppliers — contact database - Clients and suppliers — contact database
- Reminders — records with deadlines - Reminders — records with deadlines
- Reports — revenue overview, inventory status, inventory value report, stock movement list, stocktake (physical count) - Reports — revenue overview, inventory status, inventory value report, stock movement list, stocktake (physical count)
@@ -74,7 +92,7 @@ The goal is simple: everything the repair shop needs to track is located in one
### In Progress ### In Progress
- **Fiscalization (ESIR/PFR)** — Teron L-PFR mock server included in `Fisk/`; Go client integration planned - **Fiscalization on real hardware** — the full flow (issuing, refunds, daily till, closure, reports) is implemented and verified against the Teron mock server; testing against a real certified L-PFR device is still pending.
### Planned ### Planned
@@ -104,7 +122,7 @@ The goal is simple: everything the repair shop needs to track is located in one
### Requirements ### Requirements
- Go 1.24 or newer - Go 1.26 or newer
- Git - Git
### Steps ### Steps
@@ -158,6 +176,8 @@ The application reads environment variables on startup. In development, place th
| `NTECH_DSN` | — | PostgreSQL connection string | | `NTECH_DSN` | — | PostgreSQL connection string |
| `NTECH_SECRET` | — | Session signing key (min. 32 bytes); auto-generated if missing | | `NTECH_SECRET` | — | Session signing key (min. 32 bytes); auto-generated if missing |
| `NTECH_TOTP_KEY` | — | AES-256 key for TOTP secret encryption; auto-generated if missing | | `NTECH_TOTP_KEY` | — | AES-256 key for TOTP secret encryption; auto-generated if missing |
| `BE_ENABLED` | `true` | Enables the built-in card-emulator (fiscalization signing device) |
| `BE_PORT` | `4567` | TCP port for the built-in card-emulator |
`NTECH_SECRET` and `NTECH_TOTP_KEY` are generated automatically on the first run and saved to `ntech.env`. **Back this file up** — losing `NTECH_TOTP_KEY` invalidates all 2FA secrets stored in the database. `NTECH_SECRET` and `NTECH_TOTP_KEY` are generated automatically on the first run and saved to `ntech.env`. **Back this file up** — losing `NTECH_TOTP_KEY` invalidates all 2FA secrets stored in the database.
@@ -323,6 +343,20 @@ Demo also requires HTTPS (Caddy or similar) because Secure cookies are enabled.
--- ---
### Health Check
The app exposes an unauthenticated `GET /healthz` endpoint that pings the database and returns `200 OK` (or `503` if the database is unreachable). Use it for a Docker `HEALTHCHECK` or a reverse-proxy/orchestrator liveness probe:
```yaml
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/healthz"]
interval: 30s
timeout: 3s
retries: 3
```
---
## Project Structure ## Project Structure
``` ```
@@ -348,3 +382,34 @@ ntech/
├── go.mod ├── go.mod
└── go.sum └── go.sum
``` ```
---
## Testing
The project has unit and integration tests (against a real SQLite database) covering crypto, RBAC, login flows, form validators, and reports.
```bash
go test ./...
```
Migrations are numbered SQL files (`migrations/NNN_description.sql`) applied in order at startup and tracked in a `migracije` table, so they run exactly once and are safe to ship inside the same binary.
---
## Security Notes
- Sessions are server-side (random token in an `HttpOnly`, `SameSite=Strict` cookie), not JWT — revocation is immediate (delete the row).
- CSRF tokens and the card-emulator PIN are compared in constant time (`crypto/subtle`).
- Brute-force locking applies to both the password step and the TOTP/backup-code step, keyed by client IP.
- `X-Real-IP` / `X-Forwarded-For` are only trusted when the connection itself comes from a loopback or private address (i.e. a reverse proxy on the same host/Docker network) — otherwise the raw connection IP is used, so the header can't be spoofed from the internet to bypass the lockout.
- TOTP secrets are encrypted at rest (AES-256-GCM); the key (`NTECH_TOTP_KEY`) is kept outside the database.
- This is a single-tenant, single-organization application by design — there is no cross-tenant data isolation to reason about.
See [`SECURITY.md`](SECURITY.md) for how to report a vulnerability.
---
## License
[MIT](LICENSE) © Dalibor Marković
+68 -3
View File
@@ -23,6 +23,22 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
--- ---
## Kako radi
**Serversko renderovanje, bez build koraka.** Stranice se renderuju na serveru preko Go-ovog `html/template` (automatski escape, bez klijentskog šablonskog engine-a). [HTMX](https://htmx.org) menja delove stranice preko običnog HTTP-a za SPA-nalik navigaciju, a [Alpine.js](https://alpinejs.dev) pokriva manje delove klijentske interaktivnosti (live zbirovi, dinamički redovi u formama, autocomplete). Nema `npm`/webpack build korak — brauzer dobija čist HTML/CSS/JS, i jedno učitavanje stranice je dovoljno da se koristi cela aplikacija.
**Jedan binarni fajl, bez runtime zavisnosti.** Šabloni, statika (CSS/JS/slike) i SQL migracije su ugrađeni u kompajlirani binarni fajl preko `go:embed`. Deployment znači kopiranje jednog fajla (ili pokretanje jednog Docker image-a) — nema odvojenog build-a statike, nema fajlova šablona koje treba nositi uz izvršni fajl. U razvojnom modu isti kod čita direktno sa diska, pa su izmene šablona/CSS/JS-a odmah vidljive posle osvežavanja stranice, bez rebuild-a.
**Tok zahteva.** `chi` ruter provlači svaki zahtev kroz lanac middleware-a — bezbednosni headeri → CSRF (double-submit cookie) → provera sesije → provera uloge/dozvole — pre nego što stigne do handlera. Provera dozvola se izvršava na nivou rutera (da ruta ne bi slučajno ostala nezaštićena) i ponovo unutar handlera kao dodatni sloj zaštite. Handleri komuniciraju sa bazom kroz repository sloj (čist SQL, bez ORM-a) i prosleđuju obične Go strukture šablonima.
**Baza podataka.** SQLite preko čistog Go drajvera (`modernc.org/sqlite`, bez CGO-a) je podrazumevana i jedina zavisnost — jedan fajl, bez odvojenog servera baze koji treba pokretati ili bekapovati. Svako pokretanje primeni sve nove SQL migracione fajlove po redu i upiše ih u tabelu `migracije`, pa je nadogradnja samo "zameni binarni fajl i restartuj". Opcioni PostgreSQL backend (preko `pgx/v5`) je planiran za višekorisnička okruženja.
**Javne stranice za klijente.** Dva toka ne zahtevaju prijavu, samo jedinstven token koji se ne može pogoditi u URL-u: stranica statusa servisa (klijent prati napredak popravke i dobija QR kod direktno sa reversa) i stranica odobravanja predloga delova/usluga (klijent prihvata ili odbija procenu uz komentar). Oba su capability-bazirana — ko god ima link ima pristup tom jednom nalogu, ničemu drugom.
**Štampani dokumenti.** Radni nalog, predračun, otpremnica, revers i nalepnica uređaja su zasebne, samostalne HTML stranice stilizovane za A4 štampu (`@media print`), svaka sa dugmetom „Štampaj" koje otvara nativni dijalog za štampu u brauzeru — bez PDF biblioteke, bez headless-browser koraka za renderovanje. Dokumenti koji ne stanu na jednu stranicu sami se paginiraju na strani klijenta (merenjem visine sadržaja i ubacivanjem prekida strane sa brojevima strana).
---
## Funkcionalnosti ## Funkcionalnosti
### Implementirano ### Implementirano
@@ -39,12 +55,13 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
- Evidencija pokušaja prijave — istorija po korisniku, IP, razlog, datum - Evidencija pokušaja prijave — istorija po korisniku, IP, razlog, datum
- Korisnici i uloge — admin panel, upravljanje korisnicima - Korisnici i uloge — admin panel, upravljanje korisnicima
- Magacin — artikli, kategorije, filtriranje, kritični nivoi zaliha, magacinska kartica po artiklu, veza sa dobavljačima, premeštanje artikala - Magacin — artikli, kategorije, filtriranje, kritični nivoi zaliha, magacinska kartica po artiklu, veza sa dobavljačima, premeštanje artikala
- Barkod (EAN) po artiklu — pretraživ u magacinu; na ekranu prodaje, skeniranje barkoda (bilo kojim USB/Bluetooth skenerom koji „kuca" kod + Enter) automatski pronalazi artikal i dodaje ga u nalog
- Servisni nalozi: - Servisni nalozi:
- Forma prijema, statusna traka, arhiva - Forma prijema, statusna traka, arhiva
- Tok dijagnostike — opis kvara, napomene servisera, urađeno, cena dijagnostike - Tok dijagnostike — opis kvara, napomene servisera, urađeno, cena dijagnostike
- Delovi i radovi — ugrađeni artikli se skidaju sa lagera; predloženi artikli (ponuda klijentu) - Delovi i radovi — ugrađeni artikli se skidaju sa lagera; predloženi artikli (ponuda klijentu)
- Odobravanje predloga — klijent dobija javni link (QR kod) da prihvati ili odbije predlog sa komentarom - Odobravanje predloga — klijent dobija javni link (QR kod) da prihvati ili odbije predlog sa komentarom
- Javna statusna stranica — klijent prati status naloga putem jedinstvenog linka - Praćenje statusa putem QR koda — nalepnica na uređaju i svaki štampani dokument nose QR kod koji klijent skenira telefonom; otvara se mobilno optimizovana stranica (bez prijave, bez aplikacije) sa trenutnim statusom popravke, kojoj klijent može ponovo da pristupi u bilo kom trenutku istim linkom/kodom
- Dokumenti — radni nalog, predračun, otpremnica, revers, nalepnica za uređaj (QR + Code128 barkod) - Dokumenti — radni nalog, predračun, otpremnica, revers, nalepnica za uređaj (QR + Code128 barkod)
- Preuzimanje sa naplatom — način plaćanja i iznos avansa - Preuzimanje sa naplatom — način plaćanja i iznos avansa
- Garancija, predviđen datum završetka, serviser, napomena klijentu - Garancija, predviđen datum završetka, serviser, napomena klijentu
@@ -58,6 +75,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
- PDV evidencija (KIR/KPR) — knjige izdatih i primljenih računa, automatsko punjenje iz prodaje i nabavke - PDV evidencija (KIR/KPR) — knjige izdatih i primljenih računa, automatsko punjenje iz prodaje i nabavke
- PDV obračun za period + mapiranje na obrazac PP-PDV; uvoz robe (JCI) se vodi u poljima 006/106 - PDV obračun za period + mapiranje na obrazac PP-PDV; uvoz robe (JCI) se vodi u poljima 006/106
- Šifarnik PDV stopa - Šifarnik PDV stopa
- **Fiskalizacija (ESIR/L-PFR)** — pun Go klijent za Teron API fiskalnog uređaja: test konekcije, izdavanje računa (prodaja/servis, uključujući avanse i refund pri stornu), dnevni pazar, zaključenje fiskalnog dana, PDF fiskalni izveštaji, QR verifikacija računa (javna `/v/` stranica), automatski retry pri neuspešnoj fiskalizaciji sa vidljivim statusom greške, i panel za status/reset kartica-emulatora (komunicira sa uređajem za potpisivanje preko TCP-a). Trenutno provereno protiv Teron mock servera (`Fisk/`); nije još testirano na pravom sertifikovanom uređaju.
- Klijenti i dobavljači — baza kontakata - Klijenti i dobavljači — baza kontakata
- Podsetnici — evidencija sa rokom - Podsetnici — evidencija sa rokom
- Izveštaji — pregled prihoda, stanje magacina, vrednost zaliha, prometni list, popis (inventura) - Izveštaji — pregled prihoda, stanje magacina, vrednost zaliha, prometni list, popis (inventura)
@@ -74,7 +92,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
### U toku ### U toku
- **Fiskalizacija (ESIR/PFR)** — Teron L-PFR mock server dostupan u `Fisk/`; integracija Go klijenta u planu - **Fiskalizacija na pravom uređaju** — ceo tok (izdavanje, refund, dnevni pazar, zaključenje, izveštaji) je implementiran i proveren protiv Teron mock servera; testiranje na pravom sertifikovanom L-PFR uređaju još nije urađeno.
### Planirano ### Planirano
@@ -104,7 +122,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
### Zahtevi ### Zahtevi
- Go 1.24 ili noviji - Go 1.26 ili noviji
- Git - Git
### Koraci ### Koraci
@@ -158,6 +176,8 @@ Fajl `ntech.env` se **ne commituje** u Git.
| `NTECH_DSN` | — | PostgreSQL connection string | | `NTECH_DSN` | — | PostgreSQL connection string |
| `NTECH_SECRET` | — | Ključ za potpisivanje sesija (min. 32 bajta); auto-generiše se | | `NTECH_SECRET` | — | Ključ za potpisivanje sesija (min. 32 bajta); auto-generiše se |
| `NTECH_TOTP_KEY` | — | AES-256 ključ za šifrovanje TOTP tajni; auto-generiše se | | `NTECH_TOTP_KEY` | — | AES-256 ključ za šifrovanje TOTP tajni; auto-generiše se |
| `BE_ENABLED` | `true` | Uključuje ugrađeni kartica-emulator (uređaj za potpisivanje pri fiskalizaciji) |
| `BE_PORT` | `4567` | TCP port ugrađenog kartica-emulatora |
`NTECH_SECRET` i `NTECH_TOTP_KEY` se automatski generišu pri prvom pokretanju i upisuju u `ntech.env`. **Sačuvaj backup ovog fajla** — gubitak `NTECH_TOTP_KEY` onemogućuje prijavu svim korisnicima koji imaju 2FA. `NTECH_SECRET` i `NTECH_TOTP_KEY` se automatski generišu pri prvom pokretanju i upisuju u `ntech.env`. **Sačuvaj backup ovog fajla** — gubitak `NTECH_TOTP_KEY` onemogućuje prijavu svim korisnicima koji imaju 2FA.
@@ -323,6 +343,20 @@ Demo takođe zahteva HTTPS (Caddy ili slično) jer su Secure kolačići uključe
--- ---
### Health check
Aplikacija izlaže neautentifikovan `GET /healthz` endpoint koji proverava dostupnost baze i vraća `200 OK` (ili `503` ako baza nije dostupna). Koristiti za Docker `HEALTHCHECK` ili proveru živosti u reverse proxy-ju/orkestratoru:
```yaml
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/healthz"]
interval: 30s
timeout: 3s
retries: 3
```
---
## Struktura projekta ## Struktura projekta
``` ```
@@ -348,3 +382,34 @@ ntech/
├── go.mod ├── go.mod
└── go.sum └── go.sum
``` ```
---
## Testiranje
Projekat ima jedinične i integracione testove (nad pravom SQLite bazom) koji pokrivaju kripto funkcije, RBAC, tokove prijave, validatore formi i izveštaje.
```bash
go test ./...
```
Migracije su numerisani SQL fajlovi (`migrations/NNN_opis.sql`) koji se primenjuju redom pri pokretanju i prate se u tabeli `migracije` — izvršavaju se tačno jednom i bezbedno je da putuju unutar istog binarnog fajla.
---
## Bezbednosne napomene
- Sesije se čuvaju na serveru (nasumičan token u `HttpOnly`, `SameSite=Strict` kolačiću), ne JWT — opoziv je trenutan (brisanje reda).
- CSRF token i PIN kartica-emulatora se porede konstantno-vremenski (`crypto/subtle`).
- Bruteforce zaključavanje važi i za korak lozinke i za korak TOTP/rezervnog koda, po IP adresi klijenta.
- `X-Real-IP` / `X-Forwarded-For` se veruje samo kada sama konekcija dolazi sa loopback ili privatne adrese (tj. reverse proxy na istom hostu/Docker mreži) — inače se koristi sirovi IP konekcije, pa se header ne može lažirati sa interneta radi zaobilaženja zaključavanja.
- TOTP tajne su šifrovane u mirovanju (AES-256-GCM); ključ (`NTECH_TOTP_KEY`) se čuva van baze.
- Ovo je namerno jednokorisnička/jednoorganizaciona aplikacija — nema izolacije podataka između više firmi (multi-tenant) o kojoj treba brinuti.
Pogledaj [`SECURITY.md`](SECURITY.md) za način prijave bezbednosnog propusta.
---
## Licenca
[MIT](LICENSE) © Dalibor Marković
+49 -18
View File
@@ -58,7 +58,9 @@ func main() {
// kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard // kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard
if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" { if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" {
if _, err := os.Stat(envFajl); os.IsNotExist(err) { if _, err := os.Stat(envFajl); os.IsNotExist(err) {
os.WriteFile(envFajl, []byte(""), 0600) if err := os.WriteFile(envFajl, []byte(""), 0600); err != nil {
slog.Error("kreiranje praznog ntech.env nije uspelo", "putanja", envFajl, "error", err)
}
} }
} }
godotenv.Load(envFajl) godotenv.Load(envFajl)
@@ -141,7 +143,9 @@ func main() {
napraviBackup(db, putanjaBaze, max) napraviBackup(db, putanjaBaze, max)
} }
os.MkdirAll("web/static/uploads", 0755) if err := os.MkdirAll("web/static/uploads", 0755); err != nil {
slog.Error("kreiranje foldera za uploade nije uspelo", "error", err)
}
h := handler.Novi(db, totpKljuc) h := handler.Novi(db, totpKljuc)
h.Verzija = Verzija h.Verzija = Verzija
@@ -238,18 +242,44 @@ func main() {
http.FileServer(http.FS(staticFS)).ServeHTTP(w, req) http.FileServer(http.FS(staticFS)).ServeHTTP(w, req)
}))) })))
// javne rute (bez autentifikacije) // health check — bez autentifikacije, za monitoring/orkestraciju (Docker healthcheck i sl.)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
http.Error(w, "baza nedostupna", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// javne rute (bez autentifikacije), ali i dalje sa CSRF zaštitom — sprečava login-CSRF
r.Group(func(r chi.Router) {
r.Use(ntechmw.CsrfMiddleware)
r.Get("/prijava", h.PrikazPrijave) r.Get("/prijava", h.PrikazPrijave)
r.Post("/prijava", h.Prijava) r.Post("/prijava", h.Prijava)
r.Get("/prijava/totp", h.PrikazTotp) r.Get("/prijava/totp", h.PrikazTotp)
r.Post("/prijava/totp", h.VerifikujTotp) r.Post("/prijava/totp", h.VerifikujTotp)
r.Get("/setup", h.PrikazSetupa) r.Get("/setup", h.PrikazSetupa)
r.Post("/setup", h.SacuvajSetup) r.Post("/setup", h.SacuvajSetup)
})
r.Get("/odjava", h.Odjava) r.Get("/odjava", h.Odjava)
// /status/{token}/* NAMERNO je van CSRF grupe — autentikacija je jednokratni
// tajni token u URL-u (capability model), ne kolačić/sesija, pa CSRF ovde ne
// primenjuje isti pretpostavljeni napadački model (napadač bez tokena ne može
// ni da pogodi rutu).
r.Get("/status/{token}", h.ServisJavniStatus) r.Get("/status/{token}", h.ServisJavniStatus)
r.Group(func(r chi.Router) {
// telo javnih POST-ova (komentar klijenta) ograničeno na 4KB — nema razloga da bude veće
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
next.ServeHTTP(w, r)
})
})
r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati) r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati)
r.Post("/status/{token}/odbij", h.ServisJavniOdbij) r.Post("/status/{token}/odbij", h.ServisJavniOdbij)
r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano) r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano)
})
r.Get("/v/", h.FiskalVerifikacija) r.Get("/v/", h.FiskalVerifikacija)
// zaštićene rute — zahtevaju prijavljenog korisnika // zaštićene rute — zahtevaju prijavljenog korisnika
@@ -283,18 +313,19 @@ func main() {
}) })
r.Get("/dashboard", h.Dashboard) r.Get("/dashboard", h.Dashboard)
r.Get("/podesavanja", h.Podesavanja) r.Get("/podesavanja", h.Podesavanja)
r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste) pregledPodesavanja := ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "podesavanja.pregled")
r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled) r.With(pregledPodesavanja).Get("/admin/podesavanja/opste", h.PodesavanjaOpste)
r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem) r.With(pregledPodesavanja).Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled)
r.Get("/admin/podesavanja/servis", h.PodesavanjaServis) r.With(pregledPodesavanja).Get("/admin/podesavanja/sistem", h.PodesavanjaSistem)
r.Get("/admin/podesavanja/fiskalizacija", h.PodesavanjaFiskalizacija) r.With(pregledPodesavanja).Get("/admin/podesavanja/servis", h.PodesavanjaServis)
r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije) r.With(pregledPodesavanja).Get("/admin/podesavanja/fiskalizacija", h.PodesavanjaFiskalizacija)
r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus) r.With(pregledPodesavanja).Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije)
r.Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit) r.With(pregledPodesavanja).Get("/podesavanja/fiskalizacija/be-status", h.BeStatus)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit)
r.With(doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj) r.With(modul("fiskalizacija"), ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar)
r.With(doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan) r.With(modul("fiskalizacija"), doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj)
r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) r.With(modul("fiskalizacija"), doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan)
r.With(pregledPodesavanja).Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope)
@@ -349,8 +380,8 @@ func main() {
r.Get("/magacin/sledeca-sifra", h.PredlogSifre) r.Get("/magacin/sledeca-sifra", h.PredlogSifre)
r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal) r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla)
r.With(doz("artikal.obrisi")).Get("/magacin/obrisi/{id}", h.ObrisiArtikal) r.With(doz("artikal.obrisi")).Post("/magacin/obrisi/{id}", h.ObrisiArtikal)
r.With(doz("artikal.obrisi")).Get("/magacin/vrati/{id}", h.VratiArtikal) r.With(doz("artikal.obrisi")).Post("/magacin/vrati/{id}", h.VratiArtikal)
r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/dodaj", h.DodajDobavljacaArtiklu) r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/dodaj", h.DodajDobavljacaArtiklu)
r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/obrisi", h.ObrisiDobavljacaArtikla) r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/obrisi", h.ObrisiDobavljacaArtikla)
r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal) r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal)
@@ -359,7 +390,7 @@ func main() {
r.Get("/magacin/kategorije", h.Kategorije) r.Get("/magacin/kategorije", h.Kategorije)
r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju)
r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju) r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju)
r.With(doz("kategorija.obrisi")).Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka)
r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku) r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku)
+12 -1
View File
@@ -29,7 +29,18 @@ func ProveriLozinku(hash, lozinka string) bool {
// dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju. // dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju.
// Koristi ga IzjednaciVremeProvere kada korisnik ne postoji. // Koristi ga IzjednaciVremeProvere kada korisnik ne postoji.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost) var dummyHash []byte
func init() {
h, err := bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost)
if err != nil {
// cost je fiksna konstanta i lozinka nije prazna — ovo se praktično ne
// može desiti; ako se ipak desi, tiho propadanje bi obesmislilo
// anti-enumeraciju u IzjednaciVremeProvere, pa je bolje pući na startu.
panic(fmt.Sprintf("ntech: auth: generisanje dummyHash nije uspelo: %v", err))
}
dummyHash = h
}
// IzjednaciVremeProvere izvršava bcrypt poređenje protiv fiksnog heša da bi vreme // IzjednaciVremeProvere izvršava bcrypt poređenje protiv fiksnog heša da bi vreme
// odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom — // odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom —
+2 -1
View File
@@ -5,6 +5,7 @@ package be
import ( import (
"context" "context"
"crypto/subtle"
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -254,7 +255,7 @@ func (k *Kartica) cmdCertificate() map[string]any {
func (k *Kartica) cmdVerifyPin(pin string) map[string]any { func (k *Kartica) cmdVerifyPin(pin string) map[string]any {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
if pin != k.PIN { if subtle.ConstantTimeCompare([]byte(pin), []byte(k.PIN)) != 1 {
return map[string]any{"status": "error", "code": "2100", "message": "Pogrešan PIN"} return map[string]any{"status": "error", "code": "2100", "message": "Pogrešan PIN"}
} }
k.pinUnesen = true k.pinUnesen = true
+8
View File
@@ -12,11 +12,17 @@ import (
// (prodaja, nabavka, magacinske promene ili servisni nalozi). Tada se artikal arhivira. // (prodaja, nabavka, magacinske promene ili servisni nalozi). Tada se artikal arhivira.
var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi") var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi")
// ErrKategorijaUUpotrebi se vraća kad se kategorija ne može obrisati jer je
// referencirana od strane artikla.
var ErrKategorijaUUpotrebi = errors.New("ntech: kategorija je u upotrebi")
// ArtikalRepository definiše operacije nad artiklima // ArtikalRepository definiše operacije nad artiklima
type ArtikalRepository interface { type ArtikalRepository interface {
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error) Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
PrebrojiPoFilteru(ctx context.Context, filter ArtikalFilter) (int, error) PrebrojiPoFilteru(ctx context.Context, filter ArtikalFilter) (int, error)
DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error)
// DohvatiVise vraća artikle za dati skup ID-jeva kao mapu id → artikal (jedan upit)
DohvatiVise(ctx context.Context, ids []int64) (map[int64]*model.Artikal, error)
Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error)
Izmeni(ctx context.Context, a *model.Artikal) error Izmeni(ctx context.Context, a *model.Artikal) error
// AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci) // AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci)
@@ -49,6 +55,8 @@ type KategorijaRepository interface {
DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error)
Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error)
Izmeni(ctx context.Context, k *model.Kategorija) error Izmeni(ctx context.Context, k *model.Kategorija) error
// Obrisi briše kategoriju; vraća ErrKategorijaUUpotrebi ako je referencirana od artikla
Obrisi(ctx context.Context, id int64) error
} }
// PdvStopaRepository definiše operacije nad šifarnikom PDV stopa // PdvStopaRepository definiše operacije nad šifarnikom PDV stopa
+59 -14
View File
@@ -92,7 +92,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
err := redovi.Scan( err := redovi.Scan(
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere, &a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere,
&a.Kolicina, &a.KolicinMin, &a.Lokacija, &a.Kolicina, &a.KolicinaMin, &a.Lokacija,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.CenaSaPdv, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran, &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.CenaSaPdv, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
&a.KategorijaNaziv, &katMarza, &a.KategorijaNaziv, &katMarza,
) )
@@ -118,7 +118,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
} }
// kritična zaliha važi samo za proizvode (usluge/troškovi nemaju lager) // kritična zaliha važi samo za proizvode (usluge/troškovi nemaju lager)
a.KriticnaZaliha = a.PratiLager() && a.Kolicina <= a.KolicinMin a.KriticnaZaliha = a.PratiLager() && a.Kolicina <= a.KolicinaMin
rezultat = append(rezultat, a) rezultat = append(rezultat, a)
} }
@@ -126,24 +126,23 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
return rezultat, nil return rezultat, nil
} }
// DohvatiID vraća jedan artikal po ID-u const artikalKolone = `id, kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min,
func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) { lokacija, nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena, datum_unosa, arhiviran`
// skenirajArtikal čita jedan red artikla iz Scan-kompatibilnog izvora (red ili redovi)
func skenirajArtikal(scan func(...any) error) (*model.Artikal, error) {
var a model.Artikal var a model.Artikal
var kategorijaID sql.NullInt64 var kategorijaID sql.NullInt64
var sifra, barkod sql.NullString var sifra, barkod sql.NullString
var marza sql.NullFloat64 var marza sql.NullFloat64
var arhiviran int var arhiviran int
err := r.db.QueryRowContext(ctx, ` if err := scan(
SELECT id, kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min,
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena, datum_unosa, arhiviran
FROM artikli WHERE id = ?`, id).Scan(
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere, &a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere,
&a.Kolicina, &a.KolicinMin, &a.Lokacija, &a.Kolicina, &a.KolicinaMin, &a.Lokacija,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.CenaSaPdv, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran, &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.CenaSaPdv, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
) ); err != nil {
if err != nil { return nil, err
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
} }
a.Arhiviran = arhiviran == 1 a.Arhiviran = arhiviran == 1
@@ -163,6 +162,52 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
return &a, nil return &a, nil
} }
// DohvatiID vraća jedan artikal po ID-u
func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) {
row := r.db.QueryRowContext(ctx, "SELECT "+artikalKolone+" FROM artikli WHERE id = ?", id)
a, err := skenirajArtikal(row.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
}
return a, nil
}
// DohvatiVise vraća artikle za dati skup ID-jeva kao mapu id → artikal — jedan upit
// umesto poziva DohvatiID u petlji (koristi se npr. pri obradi stavki nabavke).
func (r *ArtikalRepo) DohvatiVise(ctx context.Context, ids []int64) (map[int64]*model.Artikal, error) {
rezultat := make(map[int64]*model.Artikal, len(ids))
if len(ids) == 0 {
return rezultat, nil
}
drzaci := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
drzaci[i] = "?"
args[i] = id
}
redovi, err := r.db.QueryContext(ctx,
"SELECT "+artikalKolone+" FROM artikli WHERE id IN ("+strings.Join(drzaci, ",")+")", args...)
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiVise: %w", err)
}
defer redovi.Close()
for redovi.Next() {
a, err := skenirajArtikal(redovi.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiVise: scan: %w", err)
}
rezultat[a.ID] = a
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiVise: %w", err)
}
return rezultat, nil
}
// Kreiraj dodaje novi artikal u bazu // Kreiraj dodaje novi artikal u bazu
func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) { func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) {
var sifra, barkod any var sifra, barkod any
@@ -178,7 +223,7 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
(kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, lokacija, (kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, lokacija,
nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena) nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, a.Kolicina, a.KolicinMin, a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, a.Kolicina, a.KolicinaMin,
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena,
) )
if err != nil { if err != nil {
@@ -210,7 +255,7 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, cena_sa_pdv = ?, marza = ?, napomena = ? nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, cena_sa_pdv = ?, marza = ?, napomena = ?
WHERE id = ?`, WHERE id = ?`,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere,
a.Kolicina, a.KolicinMin, a.Lokacija, a.Kolicina, a.KolicinaMin, a.Lokacija,
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.ID, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.ID,
) )
if err != nil { if err != nil {
+3 -3
View File
@@ -57,7 +57,7 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er
func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) { func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) {
var n int var n int
err := r.db.QueryRowContext(ctx, err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM artikli WHERE kolicina <= kolicina_min`).Scan(&n) `SELECT COUNT(*) FROM artikli WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0`).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: izvestaj.BrojKriticnihZaliha: %w", err) return 0, fmt.Errorf("ntech: izvestaj.BrojKriticnihZaliha: %w", err)
} }
@@ -86,7 +86,7 @@ func (r *sqliteIzvestajRepo) PoslednjiServisi(ctx context.Context, limit int) ([
func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) { func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT naziv, kolicina, kolicina_min FROM artikli SELECT naziv, kolicina, kolicina_min FROM artikli
WHERE kolicina <= kolicina_min WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0
ORDER BY kolicina ASC LIMIT ?`, limit) ORDER BY kolicina ASC LIMIT ?`, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err) return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err)
@@ -333,7 +333,7 @@ func (r *sqliteIzvestajRepo) StanjeZaliha(ctx context.Context) ([]model.StanjeZa
for rows.Next() { for rows.Next() {
var s model.StanjeZalihaRed var s model.StanjeZalihaRed
if err := rows.Scan(&s.Naziv, &s.Sifra, &s.Kategorija, if err := rows.Scan(&s.Naziv, &s.Sifra, &s.Kategorija,
&s.Kolicina, &s.KolicinMin, &s.NabavnaCena, &s.ProdajnaCena, &s.Kolicina, &s.KolicinaMin, &s.NabavnaCena, &s.ProdajnaCena,
&s.CenaSaPdv, &s.VrednostZalihe, &s.VrednostSaPdv); err != nil { &s.CenaSaPdv, &s.VrednostZalihe, &s.VrednostSaPdv); err != nil {
return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: scan: %w", err) return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: scan: %w", err)
} }
+3 -3
View File
@@ -20,9 +20,9 @@ func TestIzvestajArtikliBrojaci(t *testing.T) {
t.Fatalf("Kreiraj: %v", err) t.Fatalf("Kreiraj: %v", err)
} }
} }
dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinMin: 5}) dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinaMin: 5})
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5}) dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinaMin: 5})
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5}) dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinaMin: 5})
if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 { if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 {
t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err) t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err)
+18
View File
@@ -3,9 +3,13 @@ package sqlite
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"ntech/internal/db"
"ntech/internal/model" "ntech/internal/model"
mosqlite "modernc.org/sqlite"
) )
// KategorijaRepo je SQLite implementacija KategorijaRepository interfejsa // KategorijaRepo je SQLite implementacija KategorijaRepository interfejsa
@@ -109,3 +113,17 @@ func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error
} }
return nil return nil
} }
// Obrisi briše kategoriju. Ako je referencirana od artikla (FK ograničenje),
// vraća db.ErrKategorijaUUpotrebi da pozivalac može da prikaže razumljivu poruku.
func (r *KategorijaRepo) Obrisi(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM kategorije WHERE id = ?", id)
if err != nil {
var sqliteErr *mosqlite.Error
if errors.As(err, &sqliteErr) && sqliteErr.Code() == 787 {
return db.ErrKategorijaUUpotrebi
}
return fmt.Errorf("ntech: KategorijaRepo.Obrisi: %w", err)
}
return nil
}
+12
View File
@@ -122,6 +122,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm
FROM klijenti WHERE tip = 'pravno' AND pib = ? LIMIT 1`, jmbg) FROM klijenti WHERE tip = 'pravno' AND pib = ? LIMIT 1`, jmbg)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 2. Naziv firme + mesto // 2. Naziv firme + mesto
@@ -131,6 +133,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm
FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? AND mesto = ? LIMIT 1`, nazivFirme, mesto) FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? AND mesto = ? LIMIT 1`, nazivFirme, mesto)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 3. Samo naziv firme // 3. Samo naziv firme
@@ -152,6 +156,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm
FROM klijenti WHERE tip != 'pravno' AND jmbg = ? LIMIT 1`, jmbg) FROM klijenti WHERE tip != 'pravno' AND jmbg = ? LIMIT 1`, jmbg)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 2. Ime + prezime + mesto // 2. Ime + prezime + mesto
@@ -161,6 +167,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND mesto = ? LIMIT 1`, ime, prezime, mesto) FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND mesto = ? LIMIT 1`, ime, prezime, mesto)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 3. Ime + prezime + telefon // 3. Ime + prezime + telefon
@@ -170,6 +178,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND telefon = ? LIMIT 1`, ime, prezime, telefon) FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND telefon = ? LIMIT 1`, ime, prezime, telefon)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 4. Ime + prezime + email // 4. Ime + prezime + email
@@ -179,6 +189,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm
FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND email = ? LIMIT 1`, ime, prezime, email) FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND email = ? LIMIT 1`, ime, prezime, email)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 5. Samo ime + prezime (poslednji fallback) // 5. Samo ime + prezime (poslednji fallback)
+60
View File
@@ -100,3 +100,63 @@ func zabeleziMagacinPromenu(
} }
return nil return nil
} }
// vratiStavkeNaStanje čita (artikal_id, kolicina) iz izvorne tabele stavki
// (stavke_prodaje ili servisni_delovi — uvek fiksan literal iz poziva, nikad
// korisnički unos) za dati nalog, vraća proizvode na stanje i beleži magacinsku
// promenu za svaki. Usluge i troškovi (koji ne prate stanje) se preskaču.
// Zajednički je za Storno/Obrisi u ProdajaRepo i ServisRepo — sve unutar
// postojeće transakcije pozivaoca.
func vratiStavkeNaStanje(ctx context.Context, tx *sql.Tx, tabelaStavki string, nalogID int64, korisnikID *int64, napomena string) error {
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM "+tabelaStavki+" WHERE nalog_id = ?", nalogID)
if err != nil {
return fmt.Errorf("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("scan stavke: %w", err)
}
stavke = append(stavke, p)
}
redovi.Close()
if err := redovi.Err(); err != nil {
return fmt.Errorf("iteracija stavki: %w", err)
}
for _, p := range stavke {
var stanjePre int
var tip string
if err := tx.QueryRowContext(ctx,
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
).Scan(&stanjePre, &tip); err != nil {
return fmt.Errorf("dohvati stanje: %w", err)
}
// usluge i troškovi nemaju stanje na lageru — preskačemo povraćaj
if !(tip == model.TipProizvod || tip == "") {
continue
}
stanjePosle := stanjePre + p.kolicina
if _, err := tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
); err != nil {
return fmt.Errorf("vrati stanje: %w", err)
}
if err := zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, nalogID, korisnikID, napomena); err != nil {
return fmt.Errorf("magacin: %w", err)
}
}
return nil
}
+16 -13
View File
@@ -12,23 +12,26 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
// OtvoriDB otvara konekciju ka SQLite bazi i primenjuje performance PRAGMA podešavanja // OtvoriDB otvara konekciju ka SQLite bazi i primenjuje performance PRAGMA podešavanja.
//
// Pragme se prosleđuju kroz DSN (_pragma=...) jer database/sql drži pul konekcija —
// PRAGMA postavljena preko db.Exec važi samo za konekciju koja je taj Exec uslužila,
// dok DSN _pragma parametri primenjuje driver na SVAKU novu konekciju pri otvaranju.
func OtvoriDB(putanja string) (*sql.DB, error) { func OtvoriDB(putanja string) (*sql.DB, error) {
db, err := sql.Open("sqlite", putanja) dsn := "file:" + putanja +
"?_pragma=journal_mode(WAL)" +
"&_pragma=synchronous(NORMAL)" +
"&_pragma=cache_size(10000)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=busy_timeout(5000)" +
"&_txlock=immediate"
db, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: OtvoriDB: %w", err) return nil, fmt.Errorf("ntech: OtvoriDB: %w", err)
} }
if err := db.Ping(); err != nil {
pragme := []string{ return nil, fmt.Errorf("ntech: OtvoriDB: %w", err)
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA cache_size=10000",
"PRAGMA foreign_keys=ON",
}
for _, p := range pragme {
if _, err := db.Exec(p); err != nil {
return nil, fmt.Errorf("ntech: OtvoriDB: %s: %w", p, err)
}
} }
return db, nil return db, nil
+7 -7
View File
@@ -33,11 +33,11 @@ func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKi
args := []any{} args := []any{}
if !od.IsZero() { if !od.IsZero() {
upit += " AND datum_prometa >= ?" upit += " AND datum_prometa >= ?"
args = append(args, od) args = append(args, od.Format("2006-01-02"))
} }
if !do.IsZero() { if !do.IsZero() {
upit += " AND datum_prometa <= ?" upit += " AND datum_prometa <= ?"
args = append(args, do) args = append(args, do.Format("2006-01-02"))
} }
upit += " ORDER BY datum_prometa ASC, id ASC" upit += " ORDER BY datum_prometa ASC, id ASC"
@@ -105,7 +105,7 @@ func (r *PdvKirRepo) Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena, izvor, izvor_id osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena, izvor, izvor_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.DatumPrometa, k.DatumKnjizenja, k.BrojDokumenta, k.DatumPrometa.Format("2006-01-02"), k.DatumKnjizenja.Format("2006-01-02"), k.BrojDokumenta,
k.KupacNaziv, k.KupacPib, k.KupacMesto, k.KupacNaziv, k.KupacPib, k.KupacMesto,
k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna,
k.OslobodenSaPravom, k.OslobodenBezPrava, k.Ukupno, k.Napomena, k.OslobodenSaPravom, k.OslobodenBezPrava, k.Ukupno, k.Napomena,
@@ -229,11 +229,11 @@ func (r *PdvKprRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKp
args := []any{} args := []any{}
if !od.IsZero() { if !od.IsZero() {
upit += " AND datum_prometa >= ?" upit += " AND datum_prometa >= ?"
args = append(args, od) args = append(args, od.Format("2006-01-02"))
} }
if !do.IsZero() { if !do.IsZero() {
upit += " AND datum_prometa <= ?" upit += " AND datum_prometa <= ?"
args = append(args, do) args = append(args, do.Format("2006-01-02"))
} }
upit += " ORDER BY datum_prometa ASC, id ASC" upit += " ORDER BY datum_prometa ASC, id ASC"
@@ -302,7 +302,7 @@ func skenirajKpr(scan func(...any) error) (model.PdvKpr, error) {
func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) { func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) {
var datumPlacanja any var datumPlacanja any
if k.DatumPlacanja != nil { if k.DatumPlacanja != nil {
datumPlacanja = *k.DatumPlacanja datumPlacanja = k.DatumPlacanja.Format("2006-01-02")
} }
uvoz := 0 uvoz := 0
if k.Uvoz { if k.Uvoz {
@@ -315,7 +315,7 @@ func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id, uvoz pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id, uvoz
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.DatumPrometa, k.DatumKnjizenja, datumPlacanja, k.BrojDokumenta, k.DatumPrometa.Format("2006-01-02"), k.DatumKnjizenja.Format("2006-01-02"), datumPlacanja, k.BrojDokumenta,
k.DobavljacNaziv, k.DobavljacPib, k.DobavljacMesto, k.DobavljacNaziv, k.DobavljacPib, k.DobavljacMesto,
k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna,
k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena, k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena,
+6 -97
View File
@@ -294,53 +294,8 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
} }
// vraćanje stanja u magacin // vraćanje stanja u magacin
redovi, err := tx.QueryContext(ctx, if err := vratiStavkeNaStanje(ctx, tx, "stavke_prodaje", id, korisnikID, razlog); err != nil {
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) return fmt.Errorf("ntech: ProdajaRepo.Storno: %w", err)
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
var tip string
err := tx.QueryRowContext(ctx, "SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID).Scan(&stanjePre, &tip)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stanje: %w", err)
}
// usluge i troškovi nemaju stanje na lageru — preskačemo povraćaj
if !(tip == model.TipProizvod || tip == "") {
continue
}
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, _, err = tx.ExecContext(ctx,
@@ -374,54 +329,8 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) e
} }
if !stornirano { if !stornirano {
redovi, err := tx.QueryContext(ctx, if err := vratiStavkeNaStanje(ctx, tx, "stavke_prodaje", id, korisnikID, "brisanje prodajnog naloga"); err != nil {
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) return fmt.Errorf("ntech: ProdajaRepo.Obrisi: %w", err)
if err != nil {
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 {
// usluge i troškovi nemaju stanje — vraćamo samo proizvodima
var stanjePre int
var tip string
err := tx.QueryRowContext(ctx,
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
).Scan(&stanjePre, &tip)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stanje: %w", err)
}
if !(tip == model.TipProizvod || tip == "") {
continue
}
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.Obrisi: vrati stanje: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, id, korisnikID, "brisanje prodajnog naloga")
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: magacin: %w", err)
}
} }
} }
@@ -446,7 +355,7 @@ func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string)
// broj naloga tog dana // broj naloga tog dana
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM prodajni_nalozi SELECT COUNT(*) FROM prodajni_nalozi
WHERE klijent_id IS NULL AND stornirano = 0 AND DATE(datum) = ?`, datum, WHERE klijent_id IS NULL AND stornirano = 0 AND substr(datum, 1, 10) = ?`, datum,
).Scan(&p.BrojNaloga) ).Scan(&p.BrojNaloga)
if err != nil { if err != nil {
return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: count: %w", err) return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: count: %w", err)
@@ -458,7 +367,7 @@ func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string)
SELECT s.pdv_stopa, SUM(s.cena_bez_pdv * s.kolicina), SUM(s.pdv_iznos * s.kolicina) SELECT s.pdv_stopa, SUM(s.cena_bez_pdv * s.kolicina), SUM(s.pdv_iznos * s.kolicina)
FROM stavke_prodaje s FROM stavke_prodaje s
JOIN prodajni_nalozi p ON p.id = s.nalog_id JOIN prodajni_nalozi p ON p.id = s.nalog_id
WHERE p.klijent_id IS NULL AND p.stornirano = 0 AND DATE(p.datum) = ? WHERE p.klijent_id IS NULL AND p.stornirano = 0 AND substr(p.datum, 1, 10) = ?
GROUP BY s.pdv_stopa`, datum, GROUP BY s.pdv_stopa`, datum,
) )
if err != nil { if err != nil {
+4 -93
View File
@@ -394,53 +394,8 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
defer tx.Rollback() defer tx.Rollback()
// pokupi ugrađene delove pre brisanja (CASCADE bi ih obrisao bez povraćaja) // pokupi ugrađene delove pre brisanja (CASCADE bi ih obrisao bez povraćaja)
redovi, err := tx.QueryContext(ctx, if err := vratiStavkeNaStanje(ctx, tx, "servisni_delovi", id, korisnikID, "brisanje servisnog naloga"); err != nil {
"SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id) return fmt.Errorf("ntech: ServisRepo.Obrisi: %w", err)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Obrisi: dohvati delove: %w", err)
}
type povrat struct {
artikalID int64
kolicina int
}
var delovi []povrat
for redovi.Next() {
var p povrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ServisRepo.Obrisi: scan dela: %w", err)
}
delovi = append(delovi, p)
}
redovi.Close()
for _, p := range delovi {
// usluge i troškovi nemaju stanje na lageru — vraćamo samo proizvodima
var stanjePre int
var tip string
err := tx.QueryRowContext(ctx,
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
).Scan(&stanjePre, &tip)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Obrisi: dohvati stanje: %w", err)
}
if !(tip == model.TipProizvod || tip == "") {
continue
}
stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Obrisi: vrati stanje: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, id, korisnikID, "brisanje servisnog naloga")
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Obrisi: magacin: %w", err)
}
} }
// potraživani delovi nemaju ON DELETE CASCADE — ručno ih čistimo da brisanje ne padne na FK // potraživani delovi nemaju ON DELETE CASCADE — ručno ih čistimo da brisanje ne padne na FK
@@ -480,52 +435,8 @@ func (r *ServisRepo) Storno(ctx context.Context, id int64, razlog string, korisn
return fmt.Errorf("ntech: ServisRepo.Storno: nalog je već storniran") return fmt.Errorf("ntech: ServisRepo.Storno: nalog je već storniran")
} }
redovi, err := tx.QueryContext(ctx, if err := vratiStavkeNaStanje(ctx, tx, "servisni_delovi", id, korisnikID, "storno servisnog naloga"); err != nil {
"SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id) return fmt.Errorf("ntech: ServisRepo.Storno: %w", err)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: dohvati delove: %w", err)
}
type povrat struct {
artikalID int64
kolicina int
}
var delovi []povrat
for redovi.Next() {
var p povrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ServisRepo.Storno: scan dela: %w", err)
}
delovi = append(delovi, p)
}
redovi.Close()
for _, p := range delovi {
var stanjePre int
var tip string
err := tx.QueryRowContext(ctx,
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
).Scan(&stanjePre, &tip)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: dohvati stanje: %w", err)
}
if !(tip == model.TipProizvod || tip == "") {
continue
}
stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: vrati stanje: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, id, korisnikID, "storno servisnog naloga")
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: magacin: %w", err)
}
} }
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
+3 -4
View File
@@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"ntech/internal/model" "ntech/internal/model"
) )
@@ -80,16 +79,13 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art
func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) { func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) {
// Predloženi delovi: ne skidaju sa lagera, svaki predlog je poseban red (ne merge) // Predloženi delovi: ne skidaju sa lagera, svaki predlog je poseban red (ne merge)
if predlozeno { if predlozeno {
slog.Info("PREDLOG_INSERT", "nalogID", nalogID, "artikalID", artikalID, "kolicina", kolicina)
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)", "INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)",
nalogID, artikalID, kolicina, cenaKomada, nalogID, artikalID, kolicina, cenaKomada,
) )
if err != nil { if err != nil {
slog.Error("PREDLOG_INSERT_ERR", "err", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err) return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err)
} }
slog.Info("PREDLOG_INSERT_OK")
return 0, kolicina, nil return 0, kolicina, nil
} }
@@ -263,6 +259,9 @@ func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int
} }
stavke = append(stavke, s) stavke = append(stavke, s)
} }
if err := redovi.Err(); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: rows: %w", err)
}
// Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može) // Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može)
for _, s := range stavke { for _, s := range stavke {
+9 -1
View File
@@ -1,11 +1,14 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"ntech/internal/db"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -159,7 +162,12 @@ func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, err := h.DB.ExecContext(r.Context(), "DELETE FROM kategorije WHERE id = ?", id); err != nil { if err := h.KategorijeRepo.Obrisi(r.Context(), id); err != nil {
if errors.Is(err, db.ErrKategorijaUUpotrebi) {
middleware.SetFlash(w, r, h.DB, "greska", "Kategorija je u upotrebi kod artikala i ne može se obrisati.")
http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther)
return
}
http.Error(w, "Greška pri brisanju kategorije", http.StatusInternalServerError) http.Error(w, "Greška pri brisanju kategorije", http.StatusInternalServerError)
return return
} }
+23 -2
View File
@@ -48,6 +48,16 @@ var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "servis_eskalacioni_list", "fiskal_verifikacija", "popis_stampa", "prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "servis_eskalacioni_list", "fiskal_verifikacija", "popis_stampa",
} }
// standaloneExtraFajlovi su dodatni fajlovi (partial-i sa {{define}}) koje treba
// parsirati uz standalone šablon — npr. zajedničko zaglavlje servisnih dokumenata,
// koje se deli između revers/predračun/otpremnica/eskalacioni list.
var standaloneExtraFajlovi = map[string][]string{
"servis_revers": {"web/templates/komponente/servis_zaglavlje.html"},
"servis_predracun": {"web/templates/komponente/servis_zaglavlje.html"},
"servis_otpremnica": {"web/templates/komponente/servis_zaglavlje.html"},
"servis_eskalacioni_list": {"web/templates/komponente/servis_zaglavlje.html"},
}
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima. // sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
// dict gradi mapu iz parova ključ/vrednost — koristi se da se jednom partialu // dict gradi mapu iz parova ključ/vrednost — koristi se da se jednom partialu
// prosledi više vrednosti (npr. {{template "x" (dict "ID" .ID "Lista" $.Lista)}}). // prosledi više vrednosti (npr. {{template "x" (dict "ID" .ID "Lista" $.Lista)}}).
@@ -129,6 +139,15 @@ var sablonskeFunkcije = template.FuncMap{
}, },
// zbirF64 vraća zbir dva float64 — za aritmetiku u šablonima // zbirF64 vraća zbir dva float64 — za aritmetiku u šablonima
"zbirF64": func(a, b float64) float64 { return a + b }, "zbirF64": func(a, b float64) float64 { return a + b },
// inicijali vraća prva najviše n RUNA stringa (ne bajta) — sigurno za ćirilicu
// i č/ć/š/ž/đ, za razliku od ugrađenog {{slice}} koji seče po bajtovima.
"inicijali": func(s string, n int) string {
r := []rune(s)
if len(r) > n {
r = r[:n]
}
return string(r)
},
} }
// KreirajKes parsuje sve šablone iz fsys i vraća ih keširane u mapi // KreirajKes parsuje sve šablone iz fsys i vraća ih keširane u mapi
@@ -148,7 +167,8 @@ func KreirajKes(fsys fs.FS) (map[string]*template.Template, error) {
for _, ime := range standaloneIme { for _, ime := range standaloneIme {
// ime+".html" mora biti ime roota da bi Execute() pronašlo sadržaj fajla // ime+".html" mora biti ime roota da bi Execute() pronašlo sadržaj fajla
t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, "web/templates/stranice/"+ime+".html") fajlovi := append([]string{"web/templates/stranice/" + ime + ".html"}, standaloneExtraFajlovi[ime]...)
t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, fajlovi...)
if err != nil { if err != nil {
return nil, fmt.Errorf("kes: %s: %w", ime, err) return nil, fmt.Errorf("kes: %s: %w", ime, err)
} }
@@ -210,7 +230,8 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a
} else { } else {
var err error var err error
// kao u kreirajKes: root mora biti ime+".html" i moraju biti registrovane šablonske funkcije // kao u kreirajKes: root mora biti ime+".html" i moraju biti registrovane šablonske funkcije
if tmpl, err = template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(h.TemplatesFS, "web/templates/stranice/"+ime+".html"); err != nil { fajlovi := append([]string{"web/templates/stranice/" + ime + ".html"}, standaloneExtraFajlovi[ime]...)
if tmpl, err = template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(h.TemplatesFS, fajlovi...); err != nil {
slog.Error("greška pri parsiranju šablona", "ime", ime, "error", err) slog.Error("greška pri parsiranju šablona", "ime", ime, "error", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return return
+3 -2
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
@@ -82,10 +83,10 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) {
queryDelići := "" queryDelići := ""
if pretraga != "" { if pretraga != "" {
queryDelići += "&pretraga=" + pretraga queryDelići += "&pretraga=" + url.QueryEscape(pretraga)
} }
if tipFilter != "" { if tipFilter != "" {
queryDelići += "&tip=" + tipFilter queryDelići += "&tip=" + url.QueryEscape(tipFilter)
} }
stranicaPrev := stranicaBr - 1 stranicaPrev := stranicaBr - 1
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"ntech/internal/db" "ntech/internal/db"
@@ -104,10 +105,10 @@ func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, str
// izgradi query string za paginaciju (čuva filtere) // izgradi query string za paginaciju (čuva filtere)
queryDelići := "" queryDelići := ""
if v := filter.Pretraga; v != "" { if v := filter.Pretraga; v != "" {
queryDelići += "&pretraga=" + v queryDelići += "&pretraga=" + url.QueryEscape(v)
} }
if katIDStr != "" { if katIDStr != "" {
queryDelići += "&kategorija=" + katIDStr queryDelići += "&kategorija=" + url.QueryEscape(katIDStr)
} }
if filter.SamoKriticni { if filter.SamoKriticni {
queryDelići += "&kriticni=1" queryDelići += "&kriticni=1"
+2 -2
View File
@@ -374,13 +374,13 @@ func parseFormuArtikla(r *http.Request, podrazumevanaStopa float64) (model.Artik
if err != nil || v < 0 { if err != nil || v < 0 {
return artikal, "Minimalna količina mora biti pozitivan broj." return artikal, "Minimalna količina mora biti pozitivan broj."
} }
artikal.KolicinMin = v artikal.KolicinaMin = v
} }
// usluge i troškovi nemaju stanje na lageru // usluge i troškovi nemaju stanje na lageru
if !artikal.PratiLager() { if !artikal.PratiLager() {
artikal.Kolicina = 0 artikal.Kolicina = 0
artikal.KolicinMin = 0 artikal.KolicinaMin = 0
} }
if c := r.FormValue("nabavna_cena"); c != "" { if c := r.FormValue("nabavna_cena"); c != "" {
+17 -2
View File
@@ -196,12 +196,27 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
return return
} }
// jedan batch-dohvat svih artikala iz stavki — umesto DohvatiID po stavki u svakoj petlji ispod
artikalIDji := make([]int64, 0, len(stavke))
vidjeni := make(map[int64]bool, len(stavke))
for _, s := range stavke {
if !vidjeni[s.ArtikalID] {
vidjeni[s.ArtikalID] = true
artikalIDji = append(artikalIDji, s.ArtikalID)
}
}
artikliMapa, err := h.Artikli.DohvatiVise(r.Context(), artikalIDji)
if err != nil {
slog.Error("dohvat artikala za nabavku nije uspeo", "error", err)
artikliMapa = map[int64]*model.Artikal{}
}
// automatski zavedi u KPR ako je firma PDV obveznik; PDV se izvodi iz stope artikla // automatski zavedi u KPR ako je firma PDV obveznik; PDV se izvodi iz stope artikla
if h.modulUkljucen(r.Context(), "pdv") { if h.modulUkljucen(r.Context(), "pdv") {
var stavkePdv []model.NabavkaStavkaPdv var stavkePdv []model.NabavkaStavkaPdv
for _, s := range stavke { for _, s := range stavke {
var stopa float64 var stopa float64
if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil { if a, ok := artikliMapa[s.ArtikalID]; ok {
stopa = a.PdvStopa stopa = a.PdvStopa
} }
stavkePdv = append(stavkePdv, model.NabavkaStavkaPdv{ stavkePdv = append(stavkePdv, model.NabavkaStavkaPdv{
@@ -241,7 +256,7 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
} }
// stara prodajna i tekuća (ponderisana) nabavna — nabavnu zadržavamo // stara prodajna i tekuća (ponderisana) nabavna — nabavnu zadržavamo
var staraProdajna, nabavna float64 var staraProdajna, nabavna float64
if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil { if a, ok := artikliMapa[s.ArtikalID]; ok {
staraProdajna = a.ProdajnaCena staraProdajna = a.ProdajnaCena
nabavna = a.NabavnaCena nabavna = a.NabavnaCena
} }
+8 -5
View File
@@ -999,16 +999,18 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
klijent := &http.Client{Timeout: 5 * time.Second} klijent := &http.Client{Timeout: 5 * time.Second}
resp, err := klijent.Get(statusURL) // lgtm[go/request-forgery] -- host je validiran kroz jePrivatnaAdresa (privatne/localhost adrese) resp, err := klijent.Get(statusURL) // lgtm[go/request-forgery] -- host je validiran kroz jePrivatnaAdresa (privatne/localhost adrese)
if err != nil { if err != nil {
slog.Error("test fiskalizacije: konekcija nije uspela", "url", statusURL, "error", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Nije dostupan — %s</div>`, html.EscapeString(err.Error())) fmt.Fprint(w, `<div class="fisk-status greska">&#10007; Nije dostupan — proverite adresu i mrežu</div>`)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
var status map[string]any var status map[string]any
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
slog.Error("test fiskalizacije: neispravan odgovor servera", "url", statusURL, "error", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Neispravan odgovor servera — %s</div>`, html.EscapeString(err.Error())) fmt.Fprint(w, `<div class="fisk-status greska">&#10007; Neispravan odgovor servera</div>`)
return return
} }
@@ -1041,9 +1043,10 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second) conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil { if err != nil {
slog.Error("BeStatus: emulator nedostupan", "addr", addr, "error", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s — %s</div>`, fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s</div>`,
html.EscapeString(addr), html.EscapeString(err.Error())) html.EscapeString(addr))
return return
} }
defer conn.Close() defer conn.Close()
@@ -1090,7 +1093,7 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) {
// BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment. // BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment.
func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) { func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmena"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
return return
} }
bePort := os.Getenv("BE_PORT") bePort := os.Getenv("BE_PORT")
+44 -5
View File
@@ -186,6 +186,23 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
return return
} }
// isti bruteforce brojač kao za lozinku — deli IP-bazirano zaključavanje
ip := izvuciIP(r)
od := time.Now().Add(-prozorPrijave)
n, _ := h.PokusajiRepo.BrojNeuspeha(r.Context(), ip, od)
if n >= maxNeuspehaPrijave {
if preostalo, zaklj := h.preostaloBruteforce(r.Context(), ip, od); zaklj {
auth.LogZaklucano(ip, korisnik.KorisnickoIme)
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "ip_zaklucano_totp", false)
h.renderujStandalone(w, "totp_provera", map[string]any{
"Greska": "zakljucano",
"Preostalo": preostalo,
"CsrfToken": middleware.CsrfToken(r.Context()),
})
return
}
}
kod := r.FormValue("kod") kod := r.FormValue("kod")
validan := auth.VerifikujTotpKod(kod, korisnik.TotpTajna) validan := auth.VerifikujTotpKod(kod, korisnik.TotpTajna)
if !validan { if !validan {
@@ -196,10 +213,15 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
} }
} }
if !validan { if !validan {
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, false)
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "pogrešan_totp", false)
auth.LogNeuspehPrijave(ip, korisnik.KorisnickoIme, "wrong_totp")
http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther) http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther)
return return
} }
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, true)
novoIstice := time.Now().Add(trajanjeSeije) novoIstice := time.Now().Add(trajanjeSeije)
if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil { if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil {
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther) http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
@@ -295,9 +317,18 @@ func (h *Handler) preostaloBruteforce(ctx context.Context, ip string, od time.Ti
return fmt.Sprintf("%d sek", sek), true return fmt.Sprintf("%d sek", sek), true
} }
// izvuciIP čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja), // izvuciIP čita pravi IP klijenta iz X-Real-IP/X-Forwarded-For, ALI samo ako
// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr // zahtev fizički stiže sa poverljive adrese (Caddy na istom hostu ili Docker
// mreža — loopback/privatni opseg). Ova zaglavlja su inače potpuno pod
// kontrolom klijenta; slepo verovanje u njih zaobilazi bruteforce zaključavanje
// prijave/TOTP-a. Ako RemoteAddr nije poverljiv, header se ignoriše.
func izvuciIP(r *http.Request) string { func izvuciIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if remoteAdresaJePoverljiva(host) {
if ip := r.Header.Get("X-Real-IP"); ip != "" { if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip return ip
} }
@@ -306,13 +337,21 @@ func izvuciIP(r *http.Request) string {
parts := strings.Split(fwd, ",") parts := strings.Split(fwd, ",")
return strings.TrimSpace(parts[len(parts)-1]) return strings.TrimSpace(parts[len(parts)-1])
} }
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
} }
return host return host
} }
// remoteAdresaJePoverljiva vraća true ako je adresa loopback ili iz privatnog
// (RFC 1918) opsega — tipično za reverse proxy na istom hostu ili u istoj Docker mreži.
func remoteAdresaJePoverljiva(host string) bool {
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate()
}
func napraviKolacic(token string, istice time.Time) *http.Cookie { func napraviKolacic(token string, istice time.Time) *http.Cookie {
return &http.Cookie{ return &http.Cookie{
Name: imeKolacica, Name: imeKolacica,
+7 -4
View File
@@ -1049,12 +1049,15 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
} }
// predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje) // predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje)
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID) nalog, err := h.ServisRepo.DohvatiID(r.Context(), nalogID)
predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno if err != nil || nalog == nil {
slog.Info("DODAJ_DEO_IN", "status", nalog.Status, "predlozeno", predlozeno, "kol", kolicina) middleware.SetFlash(w, r, h.DB, "greska", "Nalog nije pronađen.")
http.Redirect(w, r, "/servis", http.StatusSeeOther)
return
}
predlozeno := nalog.Status != model.StatusPrimljeno
// atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane // atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane
ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID, predlozeno) ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID, predlozeno)
slog.Info("DODAJ_DEO_OUT", "ugradjeno", ugradjeno, "nedostaje", nedostaje, "err", err)
if err != nil { if err != nil {
slog.Error("greška pri dodavanju dela", "error", err) slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
+4 -2
View File
@@ -13,8 +13,10 @@ func TestIzvuciIP(t *testing.T) {
remoteAddr string remoteAddr string
ocek string ocek string
}{ }{
{"X-Real-IP ima prioritet", "1.2.3.4", "9.9.9.9", "5.5.5.5:1234", "1.2.3.4"}, {"X-Real-IP ima prioritet kad je proxy poverljiv (loopback)", "1.2.3.4", "9.9.9.9", "127.0.0.1:1234", "1.2.3.4"},
{"poslednji X-Forwarded-For", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "5.5.5.5:1234", "3.3.3.3"}, {"X-Real-IP ima prioritet kad je proxy poverljiv (privatna Docker mreža)", "1.2.3.4", "9.9.9.9", "172.18.0.1:1234", "1.2.3.4"},
{"poslednji X-Forwarded-For kad je proxy poverljiv", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "127.0.0.1:1234", "3.3.3.3"},
{"zaglavlje se IGNORIŠE kad RemoteAddr nije poverljiv (javna adresa — spoofing)", "1.2.3.4", "", "5.5.5.5:1234", "5.5.5.5"},
{"RemoteAddr bez porta kad nema zaglavlja", "", "", "5.5.5.5:1234", "5.5.5.5"}, {"RemoteAddr bez porta kad nema zaglavlja", "", "", "5.5.5.5:1234", "5.5.5.5"},
{"RemoteAddr kakav jeste ako nije host:port", "", "", "neispravan", "neispravan"}, {"RemoteAddr kakav jeste ako nije host:port", "", "", "neispravan", "neispravan"},
} }
+6 -1
View File
@@ -149,7 +149,12 @@ func RequireDozvolaMut(proveri func(ctx context.Context, uloga, akcija string) b
} }
} }
// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić // postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić. Namerno
// odvojen mehanizam od SetFlash/GetFlash (flash.go): ove funkcije se pozivaju
// iz middleware-a koji nema pristup *sql.DB, a redirect je uvek na /dashboard
// (jedino mesto koje čita ovaj kolačić) — pa kolačić bez DB upisa dovoljan.
// SetFlash/GetFlash (kolona sesije.flash) koristi se u handlerima koji imaju
// DB i redirect na proizvoljnu stranicu.
func postaviFlashGresku(w http.ResponseWriter, poruka string) { func postaviFlashGresku(w http.ResponseWriter, poruka string) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "ntech_flash_greska", Name: "ntech_flash_greska",
+2 -1
View File
@@ -3,6 +3,7 @@ package middleware
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/subtle"
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"os" "os"
@@ -69,7 +70,7 @@ func CsrfMiddleware(next http.Handler) http.Handler {
if submitted == "" { if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token") submitted = r.Header.Get("X-CSRF-Token")
} }
if token == "" || submitted != token { if token == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 {
http.Error(w, http.Error(w,
"Neispravan sigurnosni token. Osvežite stranicu i pokušajte ponovo.", "Neispravan sigurnosni token. Osvežite stranicu i pokušajte ponovo.",
http.StatusForbidden, http.StatusForbidden,
+5 -2
View File
@@ -3,6 +3,7 @@ package middleware
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"ntech/internal/model" "ntech/internal/model"
@@ -18,9 +19,11 @@ func SetFlash(w http.ResponseWriter, r *http.Request, db *sql.DB, tip, poruka st
if err != nil { if err != nil {
return return
} }
db.ExecContext(r.Context(), if _, err := db.ExecContext(r.Context(),
`UPDATE sesije SET flash = ? WHERE token = ?`, `UPDATE sesije SET flash = ? WHERE token = ?`,
string(data), kolacic.Value) string(data), kolacic.Value); err != nil {
slog.Error("SetFlash: upis nije uspeo", "error", err)
}
} }
// GetFlash čita i atomično briše flash poruku iz aktivne sesije // GetFlash čita i atomično briše flash poruku iz aktivne sesije
+1 -1
View File
@@ -20,7 +20,7 @@ type Artikal struct {
Tip string // proizvod | usluga | trosak Tip string // proizvod | usluga | trosak
JedinicaMere string // kom, sat, set, m, l, kg ... JedinicaMere string // kom, sat, set, m, l, kg ...
Kolicina int Kolicina int
KolicinMin int KolicinaMin int
Lokacija string Lokacija string
NabavnaCena float64 NabavnaCena float64
ProdajnaCena float64 ProdajnaCena float64
+1 -1
View File
@@ -78,7 +78,7 @@ type StanjeZalihaRed struct {
Sifra string Sifra string
Kategorija string Kategorija string
Kolicina int Kolicina int
KolicinMin int KolicinaMin int
NabavnaCena float64 NabavnaCena float64
ProdajnaCena float64 ProdajnaCena float64
CenaSaPdv float64 CenaSaPdv float64
+8 -11
View File
@@ -37,8 +37,14 @@ func (k PdvKir) OslobodenUkupno() float64 {
// OznakaPoreskogBroja vraća „JMBG" ako uneti broj ima 13 cifara (fizičko lice), // OznakaPoreskogBroja vraća „JMBG" ako uneti broj ima 13 cifara (fizičko lice),
// inače „PIB" (pravno lice / preduzetnik — PIB ima 9 cifara). // inače „PIB" (pravno lice / preduzetnik — PIB ima 9 cifara).
func (k PdvKir) OznakaPoreskogBroja() string { func (k PdvKir) OznakaPoreskogBroja() string {
return oznakaPoreskogBroja(k.KupacPib)
}
// oznakaPoreskogBroja broji cifre u poreskom broju i vraća „JMBG" (13 cifara)
// ili „PIB" (ostalo) — zajednička logika za KIR (kupac) i KPR (dobavljač).
func oznakaPoreskogBroja(poreskiBroj string) string {
cifre := 0 cifre := 0
for _, r := range k.KupacPib { for _, r := range poreskiBroj {
if r >= '0' && r <= '9' { if r >= '0' && r <= '9' {
cifre++ cifre++
} }
@@ -244,16 +250,7 @@ func KprStorno(original PdvKpr, razlog string, datumStorna time.Time) PdvKpr {
// OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme). // OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme).
func (k PdvKpr) OznakaPoreskogBroja() string { func (k PdvKpr) OznakaPoreskogBroja() string {
cifre := 0 return oznakaPoreskogBroja(k.DobavljacPib)
for _, r := range k.DobavljacPib {
if r >= '0' && r <= '9' {
cifre++
}
}
if cifre == 13 {
return "JMBG"
}
return "PIB"
} }
// PdvKprSume su zbirovi kolona KPR-a (za red „ukupno" u pregledu knjige). // PdvKprSume su zbirovi kolona KPR-a (za red „ukupno" u pregledu knjige).
+3 -1
View File
@@ -1,5 +1,7 @@
package model package model
import "time"
// Trosak je stavka šifrarnika vrsta troškova (npr. Prevoz, Carina, Ambalaža). // Trosak je stavka šifrarnika vrsta troškova (npr. Prevoz, Carina, Ambalaža).
// To je katalog dodatnih troškova koji se vezuju za radni nalog i ulaze u cenu // To je katalog dodatnih troškova koji se vezuju za radni nalog i ulaze u cenu
// koštanja. NIJE evidencija rashoda firme (struja, kirija) — to je poseban modul. // koštanja. NIJE evidencija rashoda firme (struja, kirija) — to je poseban modul.
@@ -10,5 +12,5 @@ type Trosak struct {
Cena float64 Cena float64
Opis string Opis string
Arhiviran bool Arhiviran bool
DatumUnosa string DatumUnosa time.Time
} }
+3 -1
View File
@@ -1,5 +1,7 @@
package model package model
import "time"
// Usluga je stavka cenovnika usluga (npr. čišćenje laptopa, zamena ventilatora). // Usluga je stavka cenovnika usluga (npr. čišćenje laptopa, zamena ventilatora).
// Za razliku od artikla, usluga ne prati lager i nema nabavnu cenu ni dobavljače — // Za razliku od artikla, usluga ne prati lager i nema nabavnu cenu ni dobavljače —
// ima samo cenu usluge i PDV stopu. Kategorija je tekstualna oznaka. // ima samo cenu usluge i PDV stopu. Kategorija je tekstualna oznaka.
@@ -13,7 +15,7 @@ type Usluga struct {
PdvStopa float64 PdvStopa float64
Opis string Opis string
Arhiviran bool Arhiviran bool
DatumUnosa string DatumUnosa time.Time
} }
func (u Usluga) CenaSaPdv() float64 { func (u Usluga) CenaSaPdv() float64 {
+19
View File
@@ -0,0 +1,19 @@
-- Indeksi na FK kolonama koje se često JOIN-uju/filtriraju (detalji naloga, izveštaji).
-- Bez indeksa ove tabele rade full table scan koji raste sa prometom.
CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_nalog_id ON stavke_prodaje(nalog_id);
CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_artikal_id ON stavke_prodaje(artikal_id);
CREATE INDEX IF NOT EXISTS idx_servisni_delovi_nalog_id ON servisni_delovi(nalog_id);
CREATE INDEX IF NOT EXISTS idx_servisni_delovi_artikal_id ON servisni_delovi(artikal_id);
CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_nalog_id ON servisni_potrazivani_delovi(nalog_id);
CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_artikal_id ON servisni_potrazivani_delovi(artikal_id);
CREATE INDEX IF NOT EXISTS idx_magacinske_promene_artikal_id ON magacinske_promene(artikal_id);
CREATE INDEX IF NOT EXISTS idx_stavke_nabavke_nabavka_id ON stavke_nabavke(nabavka_id);
CREATE INDEX IF NOT EXISTS idx_pdv_kir_izvor ON pdv_kir(izvor, izvor_id);
CREATE INDEX IF NOT EXISTS idx_pdv_kir_broj_dokumenta ON pdv_kir(broj_dokumenta);
CREATE INDEX IF NOT EXISTS idx_pdv_kpr_izvor ON pdv_kpr(izvor, izvor_id);
@@ -0,0 +1,39 @@
-- usluge.datum_unosa i troskovi.datum_unosa su bile deklarisane kao TEXT, za
-- razliku od svih ostalih tabela (DATETIME) — modernc.org/sqlite driver zato
-- vraća goli string umesto time.Time pri Scan-u. Fizički format podataka se ne
-- menja (SQLite nema stvarni numerički afinitet za DATETIME nad ovakvim
-- stringom), samo deklarisani tip kolone — pa je rekreacija tabele bezbedna
-- bez transformacije vrednosti.
CREATE TABLE usluge_novo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sifra TEXT,
naziv TEXT NOT NULL,
kategorija TEXT NOT NULL DEFAULT '',
jedinica_mere TEXT NOT NULL DEFAULT 'usluga',
cena REAL NOT NULL DEFAULT 0,
pdv_stopa REAL NOT NULL DEFAULT 20,
opis TEXT NOT NULL DEFAULT '',
arhiviran INTEGER NOT NULL DEFAULT 0,
datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO usluge_novo (id, sifra, naziv, kategorija, jedinica_mere, cena, pdv_stopa, opis, arhiviran, datum_unosa)
SELECT id, sifra, naziv, kategorija, jedinica_mere, cena, pdv_stopa, opis, arhiviran, datum_unosa FROM usluge;
DROP TABLE usluge;
ALTER TABLE usluge_novo RENAME TO usluge;
CREATE INDEX IF NOT EXISTS idx_usluge_arhiviran ON usluge(arhiviran);
CREATE TABLE troskovi_novo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sifra TEXT,
naziv TEXT NOT NULL,
cena REAL NOT NULL DEFAULT 0,
opis TEXT NOT NULL DEFAULT '',
arhiviran INTEGER NOT NULL DEFAULT 0,
datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO troskovi_novo (id, sifra, naziv, cena, opis, arhiviran, datum_unosa)
SELECT id, sifra, naziv, cena, opis, arhiviran, datum_unosa FROM troskovi;
DROP TABLE troskovi;
ALTER TABLE troskovi_novo RENAME TO troskovi;
CREATE INDEX IF NOT EXISTS idx_troskovi_arhiviran ON troskovi(arhiviran);
@@ -0,0 +1,36 @@
{{define "zaglavljeFirma"}}
<!-- LEVO: firma — logo na vrhu, tekst ispod -->
<div class="zg-okvir">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
{{end}}
{{define "zaglavljeKlijent"}}
<!-- klijent -->
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
{{end}}
+1 -1
View File
@@ -21,7 +21,7 @@
{{if .AvatarPutanja}} {{if .AvatarPutanja}}
<img src="{{.AvatarPutanja}}" alt="Avatar" style="width:100%;height:100%;object-fit:cover;display:block;"> <img src="{{.AvatarPutanja}}" alt="Avatar" style="width:100%;height:100%;object-fit:cover;display:block;">
{{else}} {{else}}
{{if .Korisnik}}{{slice .Korisnik 0 2}}{{else}}NT{{end}} {{if .Korisnik}}{{inicijali .Korisnik 2}}{{else}}NT{{end}}
{{end}} {{end}}
</div> </div>
<div id="avatar-meni" style="display:none;position:fixed;background:var(--kartica);backdrop-filter:none;-webkit-backdrop-filter:none;border:0.5px solid var(--ivica);border-radius:10px;box-shadow:var(--senka);min-width:160px;z-index:9999;overflow:hidden;transform-origin:top right;"> <div id="avatar-meni" style="display:none;position:fixed;background:var(--kartica);backdrop-filter:none;-webkit-backdrop-filter:none;border:0.5px solid var(--ivica);border-radius:10px;box-shadow:var(--senka);min-width:160px;z-index:9999;overflow:hidden;transform-origin:top right;">
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="kartica animiraj" style="padding: 0; overflow: hidden"> <div class="kartica animiraj" style="padding: 0; overflow: hidden">
<div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between"> <div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between">
<span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span> <span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span>
<a href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a> <a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
</div> </div>
{{if .Lista}} {{if .Lista}}
+1 -1
View File
@@ -90,7 +90,7 @@
</form> </form>
<div style="display:flex;gap:10px;flex-wrap:wrap;"> <div style="display:flex;gap:10px;flex-wrap:wrap;">
<a href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a> <a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
{{if index .Dozvole "fiskal.zakljucenje"}} {{if index .Dozvole "fiskal.zakljucenje"}}
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload <form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload
+4 -2
View File
@@ -130,10 +130,12 @@
</dialog> </dialog>
{{end}} {{end}}
{{if index $.Dozvole "kategorija.obrisi"}} {{if index $.Dozvole "kategorija.obrisi"}}
<a href="/magacin/kategorije/obrisi/{{.ID}}" class="btn-obrisi-malo" <form method="POST" action="/magacin/kategorije/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovu kategoriju?"> data-potvrda="Da li ste sigurni da želite da obrišete ovu kategoriju?">
Obriši Obriši
</a> </button>
</form>
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
+16 -8
View File
@@ -126,15 +126,19 @@
{{end}}{{end}} {{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}} {{if index $.Dozvole "artikal.obrisi"}}
{{if $.PrikazArhivirani}} {{if $.PrikazArhivirani}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo" <form method="POST" action="/magacin/vrati/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-sekundarno-malo"
data-potvrda="Vratiti ovaj artikal u aktivnu listu?"> data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
Vrati Vrati
</a> </button>
</form>
{{else}} {{else}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo" <form method="POST" action="/magacin/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?"> data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši Obriši
</a> </button>
</form>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
@@ -176,15 +180,19 @@
{{end}}{{end}} {{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}} {{if index $.Dozvole "artikal.obrisi"}}
{{if $.PrikazArhivirani}} {{if $.PrikazArhivirani}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo" <form method="POST" action="/magacin/vrati/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-sekundarno-malo"
data-potvrda="Vratiti ovaj artikal u aktivnu listu?"> data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
Vrati Vrati
</a> </button>
</form>
{{else}} {{else}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo" <form method="POST" action="/magacin/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?"> data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši Obriši
</a> </button>
</form>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
+1 -1
View File
@@ -97,7 +97,7 @@
</div> </div>
<div> <div>
<label class="polje-labela">Minimalna količina</label> <label class="polje-labela">Minimalna količina</label>
<input type="number" name="kolicina_min" value="{{.Artikal.KolicinMin}}" min="0" style="width:100%;"> <input type="number" name="kolicina_min" value="{{.Artikal.KolicinaMin}}" min="0" style="width:100%;">
</div> </div>
</div> </div>
+3 -3
View File
@@ -28,12 +28,12 @@
</div> </div>
</div> </div>
<div style="text-align:right;"> <div style="text-align:right;">
<div style="font-size:24px;font-weight:700;color:{{if le .Artikal.Kolicina .Artikal.KolicinMin}}#dc2626{{else}}var(--tekst-glavni){{end}};"> <div style="font-size:24px;font-weight:700;color:{{if le .Artikal.Kolicina .Artikal.KolicinaMin}}#dc2626{{else}}var(--tekst-glavni){{end}};">
{{.Artikal.Kolicina}} kom {{.Artikal.Kolicina}} kom
</div> </div>
<div style="font-size:12px;color:var(--tekst-slabi);">trenutno stanje</div> <div style="font-size:12px;color:var(--tekst-slabi);">trenutno stanje</div>
{{if gt .Artikal.KolicinMin 0}} {{if gt .Artikal.KolicinaMin 0}}
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:2px;">min. {{.Artikal.KolicinMin}} kom</div> <div style="font-size:11px;color:var(--tekst-slabi);margin-top:2px;">min. {{.Artikal.KolicinaMin}} kom</div>
{{end}} {{end}}
</div> </div>
</div> </div>
+1 -1
View File
@@ -282,7 +282,7 @@
</thead> </thead>
<tbody> <tbody>
{{range .Artikli}} {{range .Artikli}}
<tr class="{{if le .Kolicina .KolicinMin}}red-kriticno{{end}}" id="red-{{.ID}}"> <tr class="{{if le .Kolicina .KolicinaMin}}red-kriticno{{end}}" id="red-{{.ID}}">
<td class="kart-naslov" style="font-weight:500;">{{.Naziv}}</td> <td class="kart-naslov" style="font-weight:500;">{{.Naziv}}</td>
<td class="kol-sek" data-label="Šifra" style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td> <td class="kol-sek" data-label="Šifra" style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td class="kol-sek" data-label="Kategorija" style="font-size:12px;color:var(--tekst-slabi);">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td> <td class="kol-sek" data-label="Kategorija" style="font-size:12px;color:var(--tekst-slabi);">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
@@ -53,19 +53,7 @@
<!-- zaglavlje --> <!-- zaglavlje -->
<div class="zaglavlje"> <div class="zaglavlje">
<div class="zg-okvir"> {{template "zaglavljeFirma" .}}
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
<div class="zg-okvir" style="flex:1.3;"> <div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}} {{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
<div class="dok-tip">Eskalacioni list — praćenje rokova</div> <div class="dok-tip">Eskalacioni list — praćenje rokova</div>
+2 -31
View File
@@ -83,37 +83,8 @@
<!-- zaglavlje --> <!-- zaglavlje -->
<div class="zaglavlje"> <div class="zaglavlje">
<!-- LEVO: firma — logo na vrhu, tekst ispod --> {{template "zaglavljeFirma" .}}
<div class="zg-okvir"> {{template "zaglavljeKlijent" .}}
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
<!-- SREDINA: klijent -->
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
<!-- DESNO: barkod na vrhu, ispod datumi (levo) + QR (skroz desno) --> <!-- DESNO: barkod na vrhu, ispod datumi (levo) + QR (skroz desno) -->
<div class="zg-okvir" style="flex:1.3;"> <div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}} {{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
+2 -31
View File
@@ -77,20 +77,7 @@
<!-- zaglavlje --> <!-- zaglavlje -->
<div class="zaglavlje"> <div class="zaglavlje">
<!-- LEVO: firma — logo na vrhu, tekst ispod --> {{template "zaglavljeFirma" .}}
<div class="zg-okvir">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
<!-- SREDINA: datumi (levo) + QR (skroz desno) --> <!-- SREDINA: datumi (levo) + QR (skroz desno) -->
<div class="zg-okvir" style="flex:1.3;"> <div class="zg-okvir" style="flex:1.3;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
@@ -103,23 +90,7 @@
{{if .QRKod}}<img src="data:image/png;base64,{{.QRKod}}" width="92" height="92" alt="QR {{.Nalog.BrojNaloga}}" style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">{{end}} {{if .QRKod}}<img src="data:image/png;base64,{{.QRKod}}" width="92" height="92" alt="QR {{.Nalog.BrojNaloga}}" style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">{{end}}
</div> </div>
</div> </div>
<!-- DESNO: klijent --> {{template "zaglavljeKlijent" .}}
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
</div> </div>
<!-- uređaj i traženo --> <!-- uređaj i traženo -->
+2 -31
View File
@@ -75,20 +75,7 @@
<!-- zaglavlje --> <!-- zaglavlje -->
<div class="zaglavlje"> <div class="zaglavlje">
<!-- LEVO: firma — logo na vrhu, tekst ispod --> {{template "zaglavljeFirma" .}}
<div class="zg-okvir">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
<!-- SREDINA: barkod na vrhu, ispod dokument i datumi (QR je u sekciji „Uređaj na servisu") --> <!-- SREDINA: barkod na vrhu, ispod dokument i datumi (QR je u sekciji „Uređaj na servisu") -->
<div class="zg-okvir" style="flex:1.3;"> <div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}} {{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
@@ -99,23 +86,7 @@
<div class="dok-datum">Predviđen rok: {{.Nalog.PredvidjenDatum.Format "02.01.2006."}}</div> <div class="dok-datum">Predviđen rok: {{.Nalog.PredvidjenDatum.Format "02.01.2006."}}</div>
{{end}} {{end}}
</div> </div>
<!-- DESNO: klijent --> {{template "zaglavljeKlijent" .}}
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
</div> </div>
<!-- uređaj (QR skroz desno — klijent ga skenira da prati status) --> <!-- uređaj (QR skroz desno — klijent ga skenira da prati status) -->
+4 -4
View File
@@ -54,12 +54,12 @@
</thead> </thead>
<tbody> <tbody>
{{range .Zalihe}} {{range .Zalihe}}
<tr {{if le .Kolicina .KolicinMin}}style="background:rgba(220,38,38,0.05);"{{end}}> <tr {{if le .Kolicina .KolicinaMin}}style="background:rgba(220,38,38,0.05);"{{end}}>
<td style="font-weight:500;{{if le .Kolicina .KolicinMin}}color:#dc2626;{{end}}">{{.Naziv}}</td> <td style="font-weight:500;{{if le .Kolicina .KolicinaMin}}color:#dc2626;{{end}}">{{.Naziv}}</td>
<td style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td> <td style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td style="font-size:12px;color:var(--tekst-slabi);">{{if .Kategorija}}{{.Kategorija}}{{else}}—{{end}}</td> <td style="font-size:12px;color:var(--tekst-slabi);">{{if .Kategorija}}{{.Kategorija}}{{else}}—{{end}}</td>
<td style="text-align:right;font-weight:600;{{if le .Kolicina .KolicinMin}}color:#dc2626;{{end}}">{{.Kolicina}}</td> <td style="text-align:right;font-weight:600;{{if le .Kolicina .KolicinaMin}}color:#dc2626;{{end}}">{{.Kolicina}}</td>
<td style="text-align:right;font-size:12px;color:var(--tekst-slabi);">{{.KolicinMin}}</td> <td style="text-align:right;font-size:12px;color:var(--tekst-slabi);">{{.KolicinaMin}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .NabavnaCena}}</td> <td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .NabavnaCena}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .ProdajnaCena}}</td> <td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .ProdajnaCena}}</td>
{{if index $.Moduli "pdv"}}<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .CenaSaPdv}}</td>{{end}} {{if index $.Moduli "pdv"}}<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .CenaSaPdv}}</td>{{end}}
+5
View File
@@ -35,6 +35,11 @@
{{if eq .Greska "1"}} {{if eq .Greska "1"}}
<div class="greska">Neispravan kod. Pokušajte ponovo.</div> <div class="greska">Neispravan kod. Pokušajte ponovo.</div>
{{else if eq .Greska "zakljucano"}}
<div class="greska">
Previše neuspelih pokušaja. IP adresa je privremeno blokirana.
{{if .Preostalo}}<br>Pokušajte ponovo za: <strong>{{.Preostalo}}</strong>{{end}}
</div>
{{end}} {{end}}
<form method="POST" action="/prijava/totp"> <form method="POST" action="/prijava/totp">