Compare commits
56 Commits
be180b3e58
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
7eb472b9e6
|
|||
|
ac6deeeba4
|
|||
|
aabe19639a
|
|||
|
f4a9c1eefe
|
|||
|
91998a7736
|
|||
|
32f2235127
|
|||
|
2727b0da80
|
|||
|
b4d15f2df2
|
|||
|
830c51e95e
|
|||
|
4caadd2ef0
|
|||
|
fec84f98d5
|
|||
|
b0250b2917
|
|||
|
a8f368ca06
|
|||
|
064d6dfa2a
|
|||
|
1068bb12e0
|
|||
|
8c0e9d50a0
|
|||
|
6f0ad3f29c
|
|||
|
070f9384cf
|
|||
|
fa717208c5
|
|||
|
8855b5b84f
|
|||
|
45e4863ebb
|
|||
|
8e1cf67618
|
|||
|
fd35408da7
|
|||
|
755f56f87a
|
|||
|
4047f035da
|
|||
|
2937acfcc1
|
|||
|
f7a5d2673b
|
|||
|
880456a5ba
|
|||
|
32d7813be6
|
|||
|
10f62abf84
|
|||
|
07b851f0cf
|
|||
|
cb192a15e1
|
|||
|
41e6282404
|
|||
|
b65fb02146
|
|||
|
0f4056bd03
|
|||
|
5f017fd7ed
|
|||
|
86cbace213
|
|||
|
a3c68632be
|
|||
|
4cf061e89a
|
|||
|
8048834f87
|
|||
|
695bb3e617
|
|||
|
bdb0f4b1ae
|
|||
|
9e4d658d05
|
|||
|
a20d2baae2
|
|||
|
057c17dcdd
|
|||
|
4c549934b6
|
|||
|
fa1d6d4927
|
|||
|
b1bbe12734
|
|||
|
b07297f323
|
|||
|
1303b35387
|
|||
|
9cefd615ce
|
|||
|
1ab16c9efa
|
|||
|
1cfb44b9a4
|
|||
|
851edb06a4
|
|||
|
f29e76612e
|
|||
|
b417ff6d02
|
@@ -11,6 +11,8 @@ A business application for computer repair shop management, parts inventory trac
|
||||
|
||||
> ⚠️ The project is under active development. It is not ready for production use.
|
||||
|
||||
**Live demo:** [https://demo.vm-net.in.rs](https://demo.vm-net.in.rs) — log in with `Demo` / `Demo1234` (admin role; password and 2FA changes are disabled in demo mode).
|
||||
|
||||
---
|
||||
|
||||
## About the Project
|
||||
@@ -58,10 +60,11 @@ The goal is simple: everything the repair shop needs to track is located in one
|
||||
- Charts — monthly revenue on reports (Chart.js)
|
||||
- Structured logging — `log/slog` (JSON in production, text in development); separate auth log in fail2ban format
|
||||
- Automated tests — unit and integration over a SQLite database (crypto, RBAC, login flows, form validators, reports)
|
||||
- **Demo mode** (`NTECH_ENV=demo`) — auto-created demo user, pre-filled login form, restricted backup count, blocked password/2FA changes
|
||||
|
||||
### Planned
|
||||
|
||||
- Fiscalization (ESIR/PFR) — specification in Project.md
|
||||
- Fiscalization (ESIR/PFR)
|
||||
- KPO book and double-entry bookkeeping (optional, later phase)
|
||||
- PostgreSQL support (for multi-user environments)
|
||||
- WebAuthn / Passkey login (database schema is already prepared)
|
||||
@@ -97,11 +100,143 @@ The goal is simple: everything the repair shop needs to track is located in one
|
||||
git clone <repository-url>
|
||||
cd GoNtech
|
||||
|
||||
# 2. Copy the configuration file
|
||||
cp ntech.env.example ntech.env
|
||||
# Open ntech.env and set the values (see the table below)
|
||||
|
||||
# 3. Load environment variables and run in the development environment
|
||||
export $(grep -v '^#' ntech.env | xargs)
|
||||
# 2. Run in development mode (reads files from disk, no HTTPS required)
|
||||
go run ./cmd/ntech
|
||||
```
|
||||
|
||||
The application opens at `http://localhost:8080`. On first run the setup wizard starts automatically.
|
||||
|
||||
### Production Build
|
||||
|
||||
Use the interactive build script:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
It asks for the version, environment (production/development), platform (Linux/Windows/both), optional UPX compression, and whether to push a Docker image to Gitea and GitHub Container Registry.
|
||||
|
||||
Or build manually:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags "-X main.Verzija=1.0.0 -s -w" \
|
||||
-trimpath \
|
||||
-o ntech ./cmd/ntech
|
||||
```
|
||||
|
||||
The result is a single static binary with no external dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The application reads environment variables on startup. In development, place them in `ntech.env` alongside the SQLite database file. In production/demo the program creates `ntech.env` automatically in the same directory as the database.
|
||||
|
||||
`ntech.env` is **never committed** to Git.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------- | ------------- | ----------------------------------------------------------------- |
|
||||
| `NTECH_ENV` | `development` | Mode: `development`, `production`, or `demo` |
|
||||
| `NTECH_PORT` | `8080` | HTTP port |
|
||||
| `NTECH_DB` | `sqlite` | Database type: `sqlite` or `postgres` |
|
||||
| `NTECH_SQLITE` | `ntech.db` | Path to the SQLite file |
|
||||
| `NTECH_DSN` | — | PostgreSQL connection string |
|
||||
| `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_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.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
Docker images are published to:
|
||||
- `ghcr.io/dalibor31/ntech:latest`
|
||||
- `git.vm-net.in.rs/dasko/ntech:latest`
|
||||
|
||||
### Production
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
ntech:
|
||||
image: ghcr.io/dalibor31/ntech:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NTECH_ENV: production
|
||||
NTECH_PORT: "8000"
|
||||
NTECH_SQLITE: /app/data/ntech.db
|
||||
volumes:
|
||||
- ./data:/app/data # database + ntech.env (secrets)
|
||||
- ./uploads:/app/uploads # uploaded images
|
||||
- ./logs:/app/logs # structured + auth logs
|
||||
- ./backups:/app/backups # automatic database backups
|
||||
ports:
|
||||
- "8000:8000"
|
||||
```
|
||||
|
||||
On the **first start** the setup wizard runs and creates the first admin user. After that, `./data/ntech.env` contains the auto-generated secrets — **back it up**.
|
||||
|
||||
Place the app behind a reverse proxy (Caddy, nginx) that terminates HTTPS. Secure cookies require HTTPS.
|
||||
|
||||
Example Caddy config:
|
||||
|
||||
```
|
||||
your.domain.com {
|
||||
reverse_proxy ntech:8000
|
||||
}
|
||||
```
|
||||
|
||||
### Demo Mode
|
||||
|
||||
Demo mode runs a fully functional copy with a pre-created `Demo` / `Demo1234` admin account. Password and 2FA changes are blocked. Backup is limited to 2 copies.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (demo)
|
||||
services:
|
||||
ntech-demo:
|
||||
image: ghcr.io/dalibor31/ntech:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NTECH_ENV: demo
|
||||
NTECH_PORT: "8000"
|
||||
NTECH_SQLITE: /app/data/ntech.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
- ./logs:/app/logs
|
||||
- ./backups:/app/backups
|
||||
ports:
|
||||
- "8000:8000"
|
||||
```
|
||||
|
||||
Demo also requires HTTPS (Caddy or similar) because Secure cookies are enabled.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ntech/
|
||||
├── cmd/
|
||||
│ └── ntech/ # entry point
|
||||
├── internal/
|
||||
│ ├── auth/ # login, sessions, fail2ban log
|
||||
│ ├── config/ # settings, setup wizard
|
||||
│ ├── db/ # database layer
|
||||
│ │ └── sqlite/ # SQLite implementation
|
||||
│ ├── handler/ # HTTP handlers
|
||||
│ ├── middleware/ # CSRF, security headers, authentication
|
||||
│ └── model/ # shared data types
|
||||
├── web/
|
||||
│ ├── static/ # CSS, JavaScript, images, logos
|
||||
│ └── templates/ # HTML templates
|
||||
├── migrations/ # SQL migrations (001_desc.sql, 002_desc.sql, ...)
|
||||
├── logs/ # auth.log and other logs
|
||||
├── backups/ # database backups
|
||||
├── start.sh # interactive build and Docker push script
|
||||
├── Dockerfile
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
+100
-26
@@ -11,6 +11,8 @@ Poslovna aplikacija za upravljanje servisom računara, magacinom delova i prodaj
|
||||
|
||||
> ⚠️ Projekat je u aktivnom razvoju. Nije spreman za produkcijsku upotrebu.
|
||||
|
||||
**Demo:** [https://demo.vm-net.in.rs](https://demo.vm-net.in.rs) — prijava sa `Demo` / `Demo1234` (uloga: admin; promena lozinke i 2FA su u demo modu onemogućeni).
|
||||
|
||||
---
|
||||
|
||||
## O projektu
|
||||
@@ -58,10 +60,11 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
|
||||
- Grafikoni — mesečni prihod na izveštajima (Chart.js)
|
||||
- Strukturisano logovanje — `log/slog` (JSON u produkciji, tekst u razvoju); zaseban auth log u fail2ban formatu
|
||||
- Automatski testovi — jedinični i integracioni nad SQLite bazom (kripto, RBAC, tokovi prijave, validatori forme, izveštaji)
|
||||
- **Demo mod** (`NTECH_ENV=demo`) — automatski kreiran demo korisnik, pre-popunjeni login, ograničen bekap, blokirana promena lozinke i 2FA
|
||||
|
||||
### Planirano
|
||||
|
||||
- Fiskalizacija (ESIR/PFR) — specifikacija u Project.md
|
||||
- Fiskalizacija (ESIR/PFR)
|
||||
- KPO knjiga i dvojno knjigovodstvo (opciono, kasnija faza)
|
||||
- Podrška za PostgreSQL (za višekorisničko okruženje)
|
||||
- WebAuthn / Passkey prijava (šema baze je pripremljena)
|
||||
@@ -97,30 +100,29 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
|
||||
git clone <url-repozitorijuma>
|
||||
cd GoNtech
|
||||
|
||||
# 2. Kopiranje konfiguracionog fajla
|
||||
cp ntech.env.example ntech.env
|
||||
# Otvori ntech.env i postavi vrednosti (videti tabelu ispod)
|
||||
|
||||
# 3. Učitavanje promenljivih i pokretanje u razvojnom okruženju
|
||||
export $(grep -v '^#' ntech.env | xargs)
|
||||
# 2. Pokretanje u razvojnom modu (čita fajlove sa diska, ne zahteva HTTPS)
|
||||
go run ./cmd/ntech
|
||||
```
|
||||
|
||||
Program se otvara na `http://localhost:8080` (ili na portu definisanom u `ntech.env`).
|
||||
|
||||
Pri prvom pokretanju automatski se pokreće setup wizard.
|
||||
Program se otvara na `http://localhost:8080`. Pri prvom pokretanju automatski se pokreće setup wizard.
|
||||
|
||||
### Produkcioni build
|
||||
|
||||
```bash
|
||||
# Pomoću build.sh skripte (prima opcioni argument verzije)
|
||||
./build.sh 1.0.0
|
||||
Koristi interaktivnu skriptu:
|
||||
|
||||
# Ili ručno
|
||||
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build \
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Skripta pita za verziju, okruženje (production/development), platformu (Linux/Windows/obe), opcionalnu UPX kompresiju i da li da gurne Docker image na Gitea i GitHub Container Registry.
|
||||
|
||||
Ili ručno:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags "-X main.Verzija=1.0.0 -s -w" \
|
||||
-trimpath \
|
||||
-o ntech ./cmd/ntech
|
||||
./ntech
|
||||
```
|
||||
|
||||
Rezultat je jedan statički binarni fajl bez zavisnosti.
|
||||
@@ -129,15 +131,87 @@ Rezultat je jedan statički binarni fajl bez zavisnosti.
|
||||
|
||||
## Promenljive okruženja
|
||||
|
||||
Kopirati `ntech.env.example` u `ntech.env` i popuniti vrednosti. Fajl `ntech.env` se **ne commituje** u Git.
|
||||
Program čita promenljive okruženja pri pokretanju. U razvojnom modu staviti ih u `ntech.env` pored SQLite baze. U production/demo modu program sam kreira `ntech.env` u istom folderu gde je baza.
|
||||
|
||||
| Promenljiva | Podrazumevano | Opis |
|
||||
| -------------- | ------------- | -------------------------------------------- |
|
||||
| `NTECH_ENV` | `development` | Okruženje: `development` ili `production` |
|
||||
| `NTECH_PORT` | `8080` | HTTP port |
|
||||
| `NTECH_DB` | `sqlite` | Tip baze: `sqlite` ili `postgres` |
|
||||
| `NTECH_SQLITE` | `ntech.db` | Putanja do SQLite fajla |
|
||||
| `NTECH_DSN` | — | PostgreSQL connection string |
|
||||
Fajl `ntech.env` se **ne commituje** u Git.
|
||||
|
||||
| Promenljiva | Podrazumevano | Opis |
|
||||
| ---------------- | ------------- | ------------------------------------------------------------------ |
|
||||
| `NTECH_ENV` | `development` | Mod: `development`, `production` ili `demo` |
|
||||
| `NTECH_PORT` | `8080` | HTTP port |
|
||||
| `NTECH_DB` | `sqlite` | Tip baze: `sqlite` ili `postgres` |
|
||||
| `NTECH_SQLITE` | `ntech.db` | Putanja do SQLite fajla |
|
||||
| `NTECH_DSN` | — | PostgreSQL connection string |
|
||||
| `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_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.
|
||||
|
||||
---
|
||||
|
||||
## Docker deployment
|
||||
|
||||
Docker image je dostupan na:
|
||||
- `ghcr.io/dalibor31/ntech:latest`
|
||||
- `git.vm-net.in.rs/dasko/ntech:latest`
|
||||
|
||||
### Produkcija
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
ntech:
|
||||
image: ghcr.io/dalibor31/ntech:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NTECH_ENV: production
|
||||
NTECH_PORT: "8000"
|
||||
NTECH_SQLITE: /app/data/ntech.db
|
||||
volumes:
|
||||
- ./data:/app/data # baza + ntech.env (tajne)
|
||||
- ./uploads:/app/uploads # uploadovane slike
|
||||
- ./logs:/app/logs # strukturisani + auth log
|
||||
- ./backups:/app/backups # automatski bekap baze
|
||||
ports:
|
||||
- "8000:8000"
|
||||
```
|
||||
|
||||
Pri **prvom pokretanju** pokreće se setup wizard za kreiranje prvog admin korisnika. Nakon toga, `./data/ntech.env` sadrži auto-generisane tajne — **sačuvaj backup**.
|
||||
|
||||
Stavi program iza reverznog proksija (Caddy, nginx) koji terminira HTTPS. Secure kolačići zahtevaju HTTPS.
|
||||
|
||||
Primer Caddy konfiguracije:
|
||||
|
||||
```
|
||||
tvoj.domen.com {
|
||||
reverse_proxy ntech:8000
|
||||
}
|
||||
```
|
||||
|
||||
### Demo mod
|
||||
|
||||
Demo mod pokreće potpuno funkcionalnu kopiju sa pre-kreiranim nalogom `Demo` / `Demo1234` (admin). Promena lozinke i 2FA su blokirani. Bekap je ograničen na 2 kopije.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (demo)
|
||||
services:
|
||||
ntech-demo:
|
||||
image: ghcr.io/dalibor31/ntech:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NTECH_ENV: demo
|
||||
NTECH_PORT: "8000"
|
||||
NTECH_SQLITE: /app/data/ntech.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
- ./logs:/app/logs
|
||||
- ./backups:/app/backups
|
||||
ports:
|
||||
- "8000:8000"
|
||||
```
|
||||
|
||||
Demo takođe zahteva HTTPS (Caddy ili slično) jer su Secure kolačići uključeni.
|
||||
|
||||
---
|
||||
|
||||
@@ -161,8 +235,8 @@ ntech/
|
||||
├── migrations/ # SQL migracije (001_opis.sql, 002_opis.sql, ...)
|
||||
├── logs/ # auth.log i ostali logovi
|
||||
├── backups/ # rezervne kopije baze
|
||||
├── build.sh # skripta za produkcioni build
|
||||
├── ntech.env # lokalna konfiguracija (ne commituje se)
|
||||
├── start.sh # interaktivna skripta za build i Docker push
|
||||
├── Dockerfile
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Verzija
|
||||
# ──────────────────────────────────────────────
|
||||
read -p "Verzija (npr. 0.1.1): " VERZIJA
|
||||
VERZIJA=${VERZIJA:-"0.0.1"}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Okruženje
|
||||
# ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Okruženje:"
|
||||
echo " 1) production"
|
||||
echo " 2) development"
|
||||
read -p "Izbor [1/2, podrazumevano 1]: " OKR_IZBOR
|
||||
OKR_IZBOR=${OKR_IZBOR:-1}
|
||||
|
||||
if [ "$OKR_IZBOR" = "2" ]; then
|
||||
OKRUZENJE="development"
|
||||
LDFLAGS="-X main.Verzija=dev-${VERZIJA}"
|
||||
NAZIV="ntech-dev"
|
||||
else
|
||||
OKRUZENJE="production"
|
||||
LDFLAGS="-X main.Verzija=${VERZIJA} -s -w"
|
||||
NAZIV="ntech"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Ciljni OS
|
||||
# ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Ciljni OS:"
|
||||
echo " 1) Linux (amd64)"
|
||||
echo " 2) Windows (amd64)"
|
||||
read -p "Izbor [1/2, podrazumevano 1]: " OS_IZBOR
|
||||
OS_IZBOR=${OS_IZBOR:-1}
|
||||
|
||||
if [ "$OS_IZBOR" = "2" ]; then
|
||||
GOOS_VAL="windows"
|
||||
NAZIV="${NAZIV}.exe"
|
||||
else
|
||||
GOOS_VAL="linux"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# UPX kompresija
|
||||
# ──────────────────────────────────────────────
|
||||
echo ""
|
||||
UPX_DOSTUPAN=false
|
||||
if command -v upx &>/dev/null; then
|
||||
UPX_DOSTUPAN=true
|
||||
read -p "Kompresovati UPX-om? [d/N]: " UPX_IZBOR
|
||||
else
|
||||
echo "UPX nije instaliran — kompresija preskočena."
|
||||
UPX_IZBOR="n"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Sažetak pre builda
|
||||
# ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "──────────────────────────────────────────"
|
||||
echo " Okruženje : ${OKRUZENJE}"
|
||||
echo " Verzija : ${VERZIJA}"
|
||||
echo " OS : ${GOOS_VAL}/amd64"
|
||||
echo " Izlaz : ${NAZIV}"
|
||||
if [ "$UPX_DOSTUPAN" = true ] && [[ "$UPX_IZBOR" =~ ^[dDyY] ]]; then
|
||||
echo " UPX : da"
|
||||
else
|
||||
echo " UPX : ne"
|
||||
fi
|
||||
echo "──────────────────────────────────────────"
|
||||
echo ""
|
||||
read -p "Pokrenuti build? [D/n]: " POTVRDA
|
||||
POTVRDA=${POTVRDA:-"d"}
|
||||
if [[ ! "$POTVRDA" =~ ^[dDyY] ]]; then
|
||||
echo "Build otkazan."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build
|
||||
# ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Buildovanje..."
|
||||
CGO_ENABLED=0 GOARCH=amd64 GOOS=${GOOS_VAL} go build \
|
||||
-ldflags "${LDFLAGS}" \
|
||||
-o "${NAZIV}" \
|
||||
./cmd/ntech
|
||||
|
||||
echo "Build završen: ${NAZIV}"
|
||||
ls -lh "${NAZIV}"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# UPX
|
||||
# ──────────────────────────────────────────────
|
||||
if [ "$UPX_DOSTUPAN" = true ] && [[ "$UPX_IZBOR" =~ ^[dDyY] ]]; then
|
||||
echo ""
|
||||
echo "Kompresovanje sa UPX..."
|
||||
upx --best "${NAZIV}"
|
||||
echo "Nakon kompresije:"
|
||||
ls -lh "${NAZIV}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Gotovo."
|
||||
+96
-19
@@ -19,6 +19,7 @@ import (
|
||||
"ntech"
|
||||
"ntech/internal/auth"
|
||||
"ntech/internal/config"
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/handler"
|
||||
ntechmw "ntech/internal/middleware"
|
||||
@@ -47,7 +48,19 @@ func podesiLog() {
|
||||
func main() {
|
||||
mime.AddExtensionType(".js", "text/javascript")
|
||||
mime.AddExtensionType(".css", "text/css")
|
||||
godotenv.Load("ntech.env")
|
||||
putanjaBaze := os.Getenv("NTECH_SQLITE")
|
||||
if putanjaBaze == "" {
|
||||
putanjaBaze = "ntech.db"
|
||||
}
|
||||
envFajl := config.PutanjaNtechEnv(putanjaBaze)
|
||||
// u production/demo modu sve podešavanja dolaze iz env promenljivih —
|
||||
// kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard
|
||||
if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" {
|
||||
if _, err := os.Stat(envFajl); os.IsNotExist(err) {
|
||||
os.WriteFile(envFajl, []byte(""), 0600)
|
||||
}
|
||||
}
|
||||
godotenv.Load(envFajl)
|
||||
podesiLog()
|
||||
auth.InitAuthLog()
|
||||
|
||||
@@ -62,16 +75,16 @@ func main() {
|
||||
if _, err := os.Stat("migrations"); err == nil {
|
||||
migrFS = os.DirFS(".")
|
||||
}
|
||||
// staticFS je rootovan na "web/static" — u produkciji embed, u razvoju disk
|
||||
// staticFS je rootovan na "web/static" — u produkciji i demo modu embed, u razvoju disk
|
||||
var staticFS fs.FS
|
||||
if os.Getenv("NTECH_ENV") == "production" {
|
||||
if ntechEnv := os.Getenv("NTECH_ENV"); ntechEnv == "production" || ntechEnv == "demo" {
|
||||
staticFS, _ = fs.Sub(assets.StaticFS, "web/static")
|
||||
} else {
|
||||
staticFS = os.DirFS("web/static")
|
||||
}
|
||||
|
||||
if config.JelPrvoPokretanje() {
|
||||
config.PokreniSetup(templFS)
|
||||
if config.JelPrvoPokretanje(envFajl) {
|
||||
config.PokreniSetup(templFS, envFajl)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,11 +93,6 @@ func main() {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
putanjaBaze := os.Getenv("NTECH_SQLITE")
|
||||
if putanjaBaze == "" {
|
||||
putanjaBaze = "ntech.db"
|
||||
}
|
||||
|
||||
db, err := sqlite.OtvoriDB(putanjaBaze)
|
||||
if err != nil {
|
||||
slog.Error("Greška pri otvaranju baze", "error", err)
|
||||
@@ -111,7 +119,7 @@ func main() {
|
||||
}
|
||||
|
||||
// ključ za šifrovanje TOTP tajni u mirovanju (AES-256-GCM)
|
||||
totpKljuc, err := ucitajTotpKljuc()
|
||||
totpKljuc, err := ucitajTotpKljuc(envFajl)
|
||||
if err != nil {
|
||||
slog.Error("Greška pri učitavanju ključa za TOTP", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -124,12 +132,25 @@ func main() {
|
||||
slog.Info("šifrovane postojeće TOTP tajne", "broj", br)
|
||||
}
|
||||
|
||||
napraviBackup(db, putanjaBaze)
|
||||
{
|
||||
max := procitajIntPodesavanje(db, "backup_broj_kopija", 7)
|
||||
if os.Getenv("NTECH_ENV") == "demo" {
|
||||
max = 2
|
||||
}
|
||||
napraviBackup(db, putanjaBaze, max)
|
||||
}
|
||||
|
||||
os.MkdirAll("web/static/uploads", 0755)
|
||||
|
||||
h := handler.Novi(db, totpKljuc)
|
||||
h.Verzija = Verzija
|
||||
h.JelDemo = os.Getenv("NTECH_ENV") == "demo"
|
||||
if h.JelDemo {
|
||||
h.Verzija = "DEMO verzija"
|
||||
if err := postaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil {
|
||||
slog.Warn("demo: greška pri postavljanju demo korisnika", "error", err)
|
||||
}
|
||||
}
|
||||
// verzija statičkih fajlova za cache-busting — menja se pri svakom pokretanju,
|
||||
// pa novi build/restart natera brauzer da povuče sveži CSS/JS (umesto starog iz keša)
|
||||
h.AssetV = strconv.FormatInt(time.Now().Unix(), 36)
|
||||
@@ -137,7 +158,7 @@ func main() {
|
||||
// čuva odabrani FS (disk ili embed) za hot-reload u razvoju i za keš u produkciji
|
||||
h.TemplatesFS = templFS
|
||||
|
||||
if os.Getenv("NTECH_ENV") == "production" {
|
||||
if ntechEnv := os.Getenv("NTECH_ENV"); ntechEnv == "production" || ntechEnv == "demo" {
|
||||
kes, err := handler.KreirajKes(templFS)
|
||||
if err != nil {
|
||||
slog.Error("Greška pri kreiranju keša šablona", "error", err)
|
||||
@@ -160,7 +181,11 @@ func main() {
|
||||
})
|
||||
time.Sleep(time.Duration(sati) * time.Hour)
|
||||
h.SaBazom(func(db *sql.DB) {
|
||||
napraviBackup(db, putanjaBaze)
|
||||
max := procitajIntPodesavanje(db, "backup_broj_kopija", 7)
|
||||
if h.JelDemo {
|
||||
max = 2
|
||||
}
|
||||
napraviBackup(db, putanjaBaze, max)
|
||||
})
|
||||
}
|
||||
}()
|
||||
@@ -194,7 +219,8 @@ func main() {
|
||||
// ostali statični fajlovi: disk ako postoji web/static, inače embed.
|
||||
// U produkciji dug immutable keš (URL nosi ?v=verzija za cache-busting pri novom buildu);
|
||||
// u razvoju bez keša, da izmene CSS/JS odmah budu vidljive bez ručnog osvežavanja.
|
||||
produkcija := os.Getenv("NTECH_ENV") == "production"
|
||||
ntechEnvStr := os.Getenv("NTECH_ENV")
|
||||
produkcija := ntechEnvStr == "production" || ntechEnvStr == "demo"
|
||||
r.Handle("/static/*", http.StripPrefix("/static/",
|
||||
http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if produkcija {
|
||||
@@ -213,6 +239,7 @@ func main() {
|
||||
r.Get("/setup", h.PrikazSetupa)
|
||||
r.Post("/setup", h.SacuvajSetup)
|
||||
r.Get("/odjava", h.Odjava)
|
||||
r.Get("/status/{token}", h.ServisJavniStatus)
|
||||
|
||||
// zaštićene rute — zahtevaju prijavljenog korisnika
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -247,6 +274,7 @@ func main() {
|
||||
r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste)
|
||||
r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled)
|
||||
r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem)
|
||||
r.Get("/admin/podesavanja/servis", h.PodesavanjaServis)
|
||||
r.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/{id}/izmeni", h.IzmeniPdvStopu)
|
||||
@@ -273,11 +301,16 @@ func main() {
|
||||
r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr)
|
||||
r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica)
|
||||
r.Get("/magacin", h.Magacin)
|
||||
r.Get("/magacin/kartica/{id}", h.MagacinskaKartica)
|
||||
r.Get("/magacin/novi", h.NoviArtikal)
|
||||
r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal)
|
||||
r.Get("/magacin/sledeca-sifra", h.PredlogSifre)
|
||||
r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
|
||||
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")).Get("/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/obrisi", h.ObrisiDobavljacaArtikla)
|
||||
r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal)
|
||||
r.With(doz("artikal.izmeni")).Post("/magacin/promeni-cenu/{id}", h.PromeniCenuArtikla)
|
||||
r.With(doz("artikal.izmeni")).Get("/nivelacije", h.Nivelacije)
|
||||
@@ -309,9 +342,17 @@ func main() {
|
||||
r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
|
||||
r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/stampa", h.StampaServisa)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/predracun", h.StampaPredracuna)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi", h.DodajDeloNalogu)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga)
|
||||
r.Get("/izvestaji", h.Izvestaji)
|
||||
r.Get("/izvestaji/prometni-list", h.PrometniListMagacina)
|
||||
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
|
||||
r.Get("/izvestaji/popis", h.Popis)
|
||||
r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
|
||||
r.Get("/prodaja/nova", h.NovaProdaja)
|
||||
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
|
||||
@@ -356,6 +397,9 @@ func main() {
|
||||
r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija)
|
||||
r.Post("/admin/profil/totp/kodovi", h.AdminTotpRegenerisiKodove)
|
||||
r.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu)
|
||||
r.With(doz("tema.lokalno")).Post("/profil/animacija", h.SacuvajLokalnuAnimaciju)
|
||||
r.With(doz("tema.lokalno")).Post("/profil/hover", h.SacuvajLokalniHover)
|
||||
r.With(doz("tema.lokalno")).Post("/profil/brzina-animacije", h.SacuvajLokalnuBrzinuAnimacije)
|
||||
r.Get("/profil/tema", h.ProfilTema)
|
||||
r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu)
|
||||
r.With(doz("tema.lokalno")).Post("/profil/pozadina/ukloni", h.ProfilUkloniPozadinu)
|
||||
@@ -379,7 +423,7 @@ func main() {
|
||||
//
|
||||
// VAŽNO: ako se ovaj ključ izgubi ili promeni, postojeće šifrovane TOTP tajne se
|
||||
// više ne mogu dešifrovati (korisnici moraju ponovo aktivirati 2FA).
|
||||
func ucitajTotpKljuc() ([]byte, error) {
|
||||
func ucitajTotpKljuc(envFajl string) ([]byte, error) {
|
||||
if v := os.Getenv("NTECH_TOTP_KEY"); v != "" {
|
||||
kljuc, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
@@ -397,7 +441,7 @@ func ucitajTotpKljuc() ([]byte, error) {
|
||||
return nil, fmt.Errorf("generisanje ključa: %w", err)
|
||||
}
|
||||
enkodiran := base64.StdEncoding.EncodeToString(kljuc)
|
||||
f, err := os.OpenFile("ntech.env", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
f, err := os.OpenFile(envFajl, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("otvaranje ntech.env: %w", err)
|
||||
}
|
||||
@@ -410,9 +454,42 @@ func ucitajTotpKljuc() ([]byte, error) {
|
||||
return kljuc, nil
|
||||
}
|
||||
|
||||
// postaviDemoKorisnika osigurava da pri pokretanju demo instance postoji korisnik "Demo"
|
||||
// sa ulogom "admin" i resetovanom lozinkom. Poziva se samo kada je NTECH_ENV=demo.
|
||||
func postaviDemoKorisnika(ctx context.Context, repo db.KorisniciRepository) error {
|
||||
const (
|
||||
demoIme = "Demo"
|
||||
demoLozinka = "Demo1234"
|
||||
)
|
||||
hash, err := auth.HashujLozinku(demoLozinka)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: postaviDemoKorisnika: %w", err)
|
||||
}
|
||||
korisnik, err := repo.DohvatiPoImenu(ctx, demoIme)
|
||||
if err != nil {
|
||||
// korisnik ne postoji — kreiraj ga
|
||||
if _, err := repo.Kreiraj(ctx, demoIme, hash, "admin"); err != nil {
|
||||
return fmt.Errorf("ntech: postaviDemoKorisnika: kreiranje: %w", err)
|
||||
}
|
||||
slog.Info("demo korisnik kreiran", "korisnik", demoIme)
|
||||
return nil
|
||||
}
|
||||
// korisnik postoji — resetuj lozinku i osiguraj da je aktivan
|
||||
if err := repo.PromeniLozinku(ctx, korisnik.ID, hash); err != nil {
|
||||
return fmt.Errorf("ntech: postaviDemoKorisnika: lozinka: %w", err)
|
||||
}
|
||||
if !korisnik.Aktivan {
|
||||
if err := repo.AzurirajAktivan(ctx, korisnik.ID, true); err != nil {
|
||||
return fmt.Errorf("ntech: postaviDemoKorisnika: aktivan: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("demo korisnik resetovan", "korisnik", demoIme)
|
||||
return nil
|
||||
}
|
||||
|
||||
// napraviBackup kreira konzistentnu kopiju baze i briše najstarije preko zadatog broja kopija.
|
||||
// Koristi već otvorenu vezu ka bazi (VACUUM INTO je bezbedan na pooled konekciji).
|
||||
func napraviBackup(db *sql.DB, putanjaBaze string) {
|
||||
func napraviBackup(db *sql.DB, putanjaBaze string, maxKopija int) {
|
||||
if _, err := os.Stat(putanjaBaze); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
@@ -432,7 +509,7 @@ func napraviBackup(db *sql.DB, putanjaBaze string) {
|
||||
}
|
||||
|
||||
slog.Info("backup kreiran", "putanja", odrediste)
|
||||
ocistiStareBackupe(folder, procitajIntPodesavanje(db, "backup_broj_kopija", 7))
|
||||
ocistiStareBackupe(folder, maxKopija)
|
||||
}
|
||||
|
||||
// procitajIntPodesavanje vraća celobrojnu vrednost podešavanja iz baze,
|
||||
|
||||
@@ -12,12 +12,15 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
@@ -24,20 +26,25 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
|
||||
@@ -27,6 +27,17 @@ func ProveriLozinku(hash, lozinka string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(lozinka)) == nil
|
||||
}
|
||||
|
||||
// dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju.
|
||||
// Koristi ga IzjednaciVremeProvere kada korisnik ne postoji.
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost)
|
||||
|
||||
// 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 —
|
||||
// time se sprečava enumeracija korisničkih imena merenjem vremena odgovora.
|
||||
func IzjednaciVremeProvere(lozinka string) {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(lozinka))
|
||||
}
|
||||
|
||||
// GenerisiToken generiše nasumičan UUID token za sesiju
|
||||
func GenerisiToken() string {
|
||||
return uuid.New().String()
|
||||
|
||||
@@ -4,8 +4,17 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// PutanjaNtechEnv vraća putanju do ntech.env fajla na osnovu putanje SQLite baze.
|
||||
// Fajl se čuva u istom direktorijumu kao i baza, tako da volume mount direktorijuma
|
||||
// pokriva i bazu i konfiguraciju.
|
||||
func PutanjaNtechEnv(sqlitePutanja string) string {
|
||||
dir := filepath.Dir(sqlitePutanja)
|
||||
return filepath.Join(dir, "ntech.env")
|
||||
}
|
||||
|
||||
// lista portova koje proveravamo pri prvom pokretanju
|
||||
var kandidatPortovi = []int{8080, 3000, 8000, 9090}
|
||||
|
||||
@@ -32,8 +41,8 @@ func NadjiSlobodanPort() int {
|
||||
}
|
||||
|
||||
// proverava da li je ovo prvo pokretanje programa
|
||||
func JelPrvoPokretanje() bool {
|
||||
_, err := os.Stat("ntech.env")
|
||||
func JelPrvoPokretanje(envFajl string) bool {
|
||||
_, err := os.Stat(envFajl)
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
@@ -56,7 +65,7 @@ func StatusPortova() []PortStatus {
|
||||
}
|
||||
|
||||
// SacuvajEnv upisuje izabrani port u ntech.env fajl
|
||||
func SacuvajEnv(port int) error {
|
||||
func SacuvajEnv(port int, envFajl string) error {
|
||||
sadrzaj := fmt.Sprintf("NTECH_PORT=%d\n", port)
|
||||
return os.WriteFile("ntech.env", []byte(sadrzaj), 0600)
|
||||
return os.WriteFile(envFajl, []byte(sadrzaj), 0600)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func nadjiLokalneAdrese() []string {
|
||||
}
|
||||
|
||||
// PokreniSetup pokreće HTTP server za prvo podešavanje i čeka da korisnik završi
|
||||
func PokreniSetup(fsys fs.FS) {
|
||||
func PokreniSetup(fsys fs.FS, envFajl string) {
|
||||
port := NadjiSlobodanPort()
|
||||
if port == 0 {
|
||||
slog.Error("setup: nije pronađen nijedan slobodan port"); os.Exit(1)
|
||||
@@ -90,7 +90,7 @@ func PokreniSetup(fsys fs.FS) {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
json.NewDecoder(req.Body).Decode(&telo)
|
||||
if err := SacuvajEnv(telo.Port); err != nil {
|
||||
if err := SacuvajEnv(telo.Port, envFajl); err != nil {
|
||||
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,14 +2,20 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
// ErrArtikalUUpotrebi se vraća kad se artikal ne može obrisati jer postoji u prometu
|
||||
// (prodaja, nabavka, magacinske promene ili servisni nalozi). Tada se artikal arhivira.
|
||||
var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi")
|
||||
|
||||
// ArtikalRepository definiše operacije nad artiklima
|
||||
type ArtikalRepository interface {
|
||||
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
|
||||
PrebrojiPoFilteru(ctx context.Context, filter ArtikalFilter) (int, error)
|
||||
DohvatiID(ctx context.Context, id int64) (*model.Artikal, error)
|
||||
Kreiraj(ctx context.Context, a *model.Artikal) (int64, error)
|
||||
Izmeni(ctx context.Context, a *model.Artikal) error
|
||||
@@ -17,6 +23,24 @@ type ArtikalRepository interface {
|
||||
AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error
|
||||
PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
// Arhiviraj označava artikal kao arhiviran (skriva ga iz aktivne liste, čuva istoriju)
|
||||
Arhiviraj(ctx context.Context, id int64) error
|
||||
// Vrati poništava arhiviranje i vraća artikal u aktivnu listu
|
||||
Vrati(ctx context.Context, id int64) error
|
||||
// SledecaSifra vraća predlog sledeće auto-šifre (npr. KOMP-0042 ili ART-0042)
|
||||
SledecaSifra(ctx context.Context, kategorijaID *int64) (string, error)
|
||||
// KorigujKolicinu postavlja novu količinu artikla i upisuje korekciju u magacinske_promene
|
||||
KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error
|
||||
// DobavljaciArtikla vraća ID-jeve dobavljača vezanih za artikal
|
||||
DobavljaciArtikla(ctx context.Context, artikalID int64) ([]int64, error)
|
||||
// PostaviDobavljaceArtikla zamenjuje skup dobavljača artikla datim ID-jevima
|
||||
PostaviDobavljaceArtikla(ctx context.Context, artikalID int64, dobavljaciID []int64) error
|
||||
// PoveziDobavljaca dodaje vezu artikal–dobavljač ako ne postoji (auto pri nabavci)
|
||||
PoveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error
|
||||
// OdveziDobavljaca uklanja vezu artikal–dobavljač
|
||||
OdveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error
|
||||
// SveDobavljaceArtikala vraća mapu artikal_id → lista dobavljac_id (za filter u nabavci)
|
||||
SveDobavljaceArtikala(ctx context.Context) (map[int64][]int64, error)
|
||||
}
|
||||
|
||||
// KategorijaRepository definiše operacije nad kategorijama
|
||||
@@ -74,6 +98,9 @@ type ArtikalFilter struct {
|
||||
Pretraga string
|
||||
KategorijaID *int64
|
||||
SamoKriticni bool
|
||||
Arhivirani bool // true → vrati samo arhivirane; false (podrazumevano) → samo aktivne
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// NabavkaRepository definiše operacije nad nabavkama
|
||||
@@ -95,9 +122,19 @@ type DobavljacRepository interface {
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// KlijentFilter definiše parametre za filtriranje liste klijenata
|
||||
type KlijentFilter struct {
|
||||
Pretraga string
|
||||
Tip string // "fizicko", "pravno" ili "" za sve
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// KlijentRepository definiše operacije nad klijentima
|
||||
type KlijentRepository interface {
|
||||
Lista(ctx context.Context, pretraga string) ([]model.Klijent, error)
|
||||
ListaFilter(ctx context.Context, filter KlijentFilter) ([]model.Klijent, error)
|
||||
PrebrojiPoFilteru(ctx context.Context, filter KlijentFilter) (int, error)
|
||||
DohvatiID(ctx context.Context, id int64) (*model.Klijent, error)
|
||||
Kreiraj(ctx context.Context, k *model.Klijent) (int64, error)
|
||||
Izmeni(ctx context.Context, k *model.Klijent) error
|
||||
@@ -108,8 +145,10 @@ type KlijentRepository interface {
|
||||
type ServisRepository interface {
|
||||
Lista(ctx context.Context, pretraga, status string) ([]model.ServisniNalogSaKlijentom, error)
|
||||
DohvatiID(ctx context.Context, id int64) (*model.ServisniNalog, error)
|
||||
DohvatiJavniToken(ctx context.Context, token string) (*model.ServisniNalog, error)
|
||||
Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error)
|
||||
Izmeni(ctx context.Context, n *model.ServisniNalog) error
|
||||
AzurirajStatus(ctx context.Context, id int64, status string) error
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
SledeciBroj(ctx context.Context) (string, error)
|
||||
}
|
||||
@@ -121,7 +160,7 @@ type ProdajaRepository interface {
|
||||
DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error)
|
||||
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error)
|
||||
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
|
||||
SledeciBroj(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
@@ -149,6 +188,9 @@ type KorisniciRepository interface {
|
||||
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
|
||||
SacuvajLokalnuTemu(ctx context.Context, id int64, lokalnaTema string, koristi bool) error
|
||||
SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error
|
||||
SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error
|
||||
SacuvajLokalniHover(ctx context.Context, id int64, hover string) error
|
||||
SacuvajLokalnuBrzinuAnimacije(ctx context.Context, id int64, brzina string) error
|
||||
SacuvajAvatar(ctx context.Context, id int64, putanja string) error
|
||||
PostojiIjedan(ctx context.Context) (bool, error)
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
@@ -218,6 +260,9 @@ type IzvestajRepository interface {
|
||||
StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error)
|
||||
TopArtikli(ctx context.Context, limit int) ([]model.TopArtikalRed, error)
|
||||
TopKlijenti(ctx context.Context, limit int) ([]model.TopKlijentRed, error)
|
||||
// magacinski izveštaji
|
||||
PrometniList(ctx context.Context, od, do time.Time) ([]model.PrometniRed, error)
|
||||
StanjeZaliha(ctx context.Context) ([]model.StanjeZalihaRed, error)
|
||||
}
|
||||
|
||||
// PodsetnikRepository definiše operacije nad podsetnicima
|
||||
|
||||
+290
-20
@@ -3,10 +3,15 @@ package sqlite
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/model"
|
||||
|
||||
mosqlite "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// ArtikalRepo je SQLite implementacija ArtikalRepository interfejsa
|
||||
@@ -23,9 +28,9 @@ func NoviArtikalRepo(db *sql.DB) *ArtikalRepo {
|
||||
func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]model.ArtikalSaKategorijom, error) {
|
||||
upit := `
|
||||
SELECT
|
||||
a.id, a.kategorija_id, a.naziv, a.opis,
|
||||
a.id, a.kategorija_id, a.sifra, a.barkod, a.naziv, a.opis, a.tip, a.jedinica_mere,
|
||||
a.kolicina, a.kolicina_min, a.lokacija,
|
||||
a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.marza, a.napomena, a.datum_unosa,
|
||||
a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.marza, a.napomena, a.datum_unosa, a.arhiviran,
|
||||
COALESCE(k.naziv, '') as kategorija_naziv, k.marza as kategorija_marza
|
||||
FROM artikli a
|
||||
LEFT JOIN kategorije k ON a.kategorija_id = k.id
|
||||
@@ -34,9 +39,9 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
|
||||
args := []any{}
|
||||
|
||||
if filter.Pretraga != "" {
|
||||
upit += " AND (a.naziv LIKE ? OR a.lokacija LIKE ? OR k.naziv LIKE ?)"
|
||||
upit += " AND (a.naziv LIKE ? OR a.sifra LIKE ? OR a.barkod LIKE ? OR a.lokacija LIKE ? OR k.naziv LIKE ?)"
|
||||
t := "%" + filter.Pretraga + "%"
|
||||
args = append(args, t, t, t)
|
||||
args = append(args, t, t, t, t, t)
|
||||
}
|
||||
|
||||
if filter.KategorijaID != nil {
|
||||
@@ -45,11 +50,27 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
|
||||
}
|
||||
|
||||
if filter.SamoKriticni {
|
||||
upit += " AND a.kolicina <= a.kolicina_min"
|
||||
upit += " AND (a.tip = 'proizvod' OR a.tip = '') AND a.kolicina <= a.kolicina_min"
|
||||
}
|
||||
|
||||
// podrazumevano vraćamo samo aktivne; arhivirani se prikazuju samo na izričit zahtev
|
||||
if filter.Arhivirani {
|
||||
upit += " AND a.arhiviran = 1"
|
||||
} else {
|
||||
upit += " AND a.arhiviran = 0"
|
||||
}
|
||||
|
||||
upit += " ORDER BY a.naziv ASC"
|
||||
|
||||
if filter.Limit > 0 {
|
||||
upit += " LIMIT ?"
|
||||
args = append(args, filter.Limit)
|
||||
if filter.Offset > 0 {
|
||||
upit += " OFFSET ?"
|
||||
args = append(args, filter.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
redovi, err := r.db.QueryContext(ctx, upit, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: %w", err)
|
||||
@@ -60,21 +81,30 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
|
||||
for redovi.Next() {
|
||||
var a model.ArtikalSaKategorijom
|
||||
var kategorijaID sql.NullInt64
|
||||
var sifra, barkod sql.NullString
|
||||
var marza, katMarza sql.NullFloat64
|
||||
var arhiviran int
|
||||
|
||||
err := redovi.Scan(
|
||||
&a.ID, &kategorijaID, &a.Naziv, &a.Opis,
|
||||
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere,
|
||||
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
|
||||
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa,
|
||||
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
|
||||
&a.KategorijaNaziv, &katMarza,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: scan: %w", err)
|
||||
}
|
||||
a.Arhiviran = arhiviran == 1
|
||||
|
||||
if kategorijaID.Valid {
|
||||
a.KategorijaID = &kategorijaID.Int64
|
||||
}
|
||||
if sifra.Valid {
|
||||
a.Sifra = sifra.String
|
||||
}
|
||||
if barkod.Valid {
|
||||
a.Barkod = barkod.String
|
||||
}
|
||||
if marza.Valid {
|
||||
a.Marza = &marza.Float64
|
||||
}
|
||||
@@ -82,7 +112,8 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
|
||||
a.KategorijaMarza = &katMarza.Float64
|
||||
}
|
||||
|
||||
a.KriticnaZaliha = a.Kolicina <= a.KolicinMin
|
||||
// kritična zaliha važi samo za proizvode (usluge/troškovi nemaju lager)
|
||||
a.KriticnaZaliha = a.PratiLager() && a.Kolicina <= a.KolicinMin
|
||||
|
||||
rezultat = append(rezultat, a)
|
||||
}
|
||||
@@ -94,23 +125,32 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
|
||||
func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) {
|
||||
var a model.Artikal
|
||||
var kategorijaID sql.NullInt64
|
||||
var sifra, barkod sql.NullString
|
||||
var marza sql.NullFloat64
|
||||
var arhiviran int
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, kategorija_id, naziv, opis, kolicina, kolicina_min,
|
||||
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa
|
||||
SELECT id, kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min,
|
||||
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa, arhiviran
|
||||
FROM artikli WHERE id = ?`, id).Scan(
|
||||
&a.ID, &kategorijaID, &a.Naziv, &a.Opis,
|
||||
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere,
|
||||
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
|
||||
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa,
|
||||
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
|
||||
}
|
||||
a.Arhiviran = arhiviran == 1
|
||||
|
||||
if kategorijaID.Valid {
|
||||
a.KategorijaID = &kategorijaID.Int64
|
||||
}
|
||||
if sifra.Valid {
|
||||
a.Sifra = sifra.String
|
||||
}
|
||||
if barkod.Valid {
|
||||
a.Barkod = barkod.String
|
||||
}
|
||||
if marza.Valid {
|
||||
a.Marza = &marza.Float64
|
||||
}
|
||||
@@ -120,12 +160,20 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
|
||||
|
||||
// Kreiraj dodaje novi artikal u bazu
|
||||
func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) {
|
||||
var sifra, barkod any
|
||||
if a.Sifra != "" {
|
||||
sifra = a.Sifra
|
||||
}
|
||||
if a.Barkod != "" {
|
||||
barkod = a.Barkod
|
||||
}
|
||||
|
||||
rezultat, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO artikli
|
||||
(kategorija_id, naziv, opis, kolicina, kolicina_min, lokacija,
|
||||
(kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, lokacija,
|
||||
nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, a.Kolicina, a.KolicinMin,
|
||||
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -142,14 +190,22 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
|
||||
|
||||
// Izmeni ažurira postojeći artikal
|
||||
func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
|
||||
var sifra, barkod any
|
||||
if a.Sifra != "" {
|
||||
sifra = a.Sifra
|
||||
}
|
||||
if a.Barkod != "" {
|
||||
barkod = a.Barkod
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE artikli SET
|
||||
kategorija_id = ?, naziv = ?, opis = ?, kolicina = ?,
|
||||
kolicina_min = ?, lokacija = ?,
|
||||
kategorija_id = ?, sifra = ?, barkod = ?, naziv = ?, opis = ?, tip = ?, jedinica_mere = ?,
|
||||
kolicina = ?, kolicina_min = ?, lokacija = ?,
|
||||
nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, marza = ?, napomena = ?
|
||||
WHERE id = ?`,
|
||||
a.KategorijaID, a.Naziv, a.Opis, a.Kolicina,
|
||||
a.KolicinMin, a.Lokacija,
|
||||
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere,
|
||||
a.Kolicina, a.KolicinMin, a.Lokacija,
|
||||
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, a.ID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -159,6 +215,45 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SledecaSifra vraća predlog sledeće auto-šifre u formatu PREFIKS-NNNN.
|
||||
// Prefiks je kôd kategorije (npr. KOMP) ili "ART" ako kategorija nema kôd ili nije zadata.
|
||||
// Brojač je najveći postojeći broj za taj prefiks + 1 (otporno na brisanje artikala).
|
||||
func (r *ArtikalRepo) SledecaSifra(ctx context.Context, kategorijaID *int64) (string, error) {
|
||||
prefiks := "ART"
|
||||
if kategorijaID != nil {
|
||||
var kod sql.NullString
|
||||
if err := r.db.QueryRowContext(ctx,
|
||||
"SELECT kod FROM kategorije WHERE id = ?", *kategorijaID).Scan(&kod); err == nil {
|
||||
if kod.Valid && kod.String != "" {
|
||||
prefiks = kod.String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redovi, err := r.db.QueryContext(ctx, "SELECT sifra FROM artikli WHERE sifra LIKE ?", prefiks+"-%")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: ArtikalRepo.SledecaSifra: %w", err)
|
||||
}
|
||||
defer redovi.Close()
|
||||
|
||||
maxBroj := 0
|
||||
for redovi.Next() {
|
||||
var s string
|
||||
if err := redovi.Scan(&s); err != nil {
|
||||
continue
|
||||
}
|
||||
i := strings.LastIndex(s, "-")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(s[i+1:]); err == nil && n > maxBroj {
|
||||
maxBroj = n
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-%04d", prefiks, maxBroj+1), nil
|
||||
}
|
||||
|
||||
// AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe).
|
||||
func (r *ArtikalRepo) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
@@ -180,12 +275,187 @@ func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategori
|
||||
return nil
|
||||
}
|
||||
|
||||
// Obrisi briše artikal po ID-u
|
||||
// Obrisi briše artikal po ID-u. Ako je artikal u prometu (FK RESTRICT), vraća
|
||||
// db.ErrArtikalUUpotrebi kako bi pozivalac mogao da ga arhivira umesto da ga obriše.
|
||||
func (r *ArtikalRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM artikli WHERE id = ?", id)
|
||||
if err != nil {
|
||||
// SQLITE_CONSTRAINT_FOREIGNKEY (787) — artikal je referenciran iz druge tabele
|
||||
var sqliteErr *mosqlite.Error
|
||||
if errors.As(err, &sqliteErr) && sqliteErr.Code() == 787 {
|
||||
return db.ErrArtikalUUpotrebi
|
||||
}
|
||||
return fmt.Errorf("ntech: ArtikalRepo.Obrisi: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Arhiviraj označava artikal kao arhiviran (soft delete za artikle u prometu)
|
||||
func (r *ArtikalRepo) Arhiviraj(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, "UPDATE artikli SET arhiviran = 1 WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.Arhiviraj: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Vrati poništava arhiviranje i vraća artikal u aktivnu listu
|
||||
func (r *ArtikalRepo) Vrati(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, "UPDATE artikli SET arhiviran = 0 WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.Vrati: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DobavljaciArtikla vraća ID-jeve dobavljača vezanih za artikal
|
||||
func (r *ArtikalRepo) DobavljaciArtikla(ctx context.Context, artikalID int64) ([]int64, error) {
|
||||
redovi, err := r.db.QueryContext(ctx,
|
||||
"SELECT dobavljac_id FROM artikal_dobavljac WHERE artikal_id = ? ORDER BY dobavljac_id", artikalID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: ArtikalRepo.DobavljaciArtikla: %w", err)
|
||||
}
|
||||
defer redovi.Close()
|
||||
|
||||
var ids []int64
|
||||
for redovi.Next() {
|
||||
var id int64
|
||||
if err := redovi.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("ntech: ArtikalRepo.DobavljaciArtikla: scan: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// PostaviDobavljaceArtikla zamenjuje skup dobavljača artikla datim ID-jevima (u transakciji)
|
||||
func (r *ArtikalRepo) PostaviDobavljaceArtikla(ctx context.Context, artikalID int64, dobavljaciID []int64) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM artikal_dobavljac WHERE artikal_id = ?", artikalID); err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: delete: %w", err)
|
||||
}
|
||||
for _, did := range dobavljaciID {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"INSERT OR IGNORE INTO artikal_dobavljac (artikal_id, dobavljac_id) VALUES (?, ?)", artikalID, did); err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: insert: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.PostaviDobavljaceArtikla: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PoveziDobavljaca dodaje vezu artikal–dobavljač ako ne postoji (auto pri nabavci)
|
||||
func (r *ArtikalRepo) PoveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"INSERT OR IGNORE INTO artikal_dobavljac (artikal_id, dobavljac_id) VALUES (?, ?)", artikalID, dobavljacID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.PoveziDobavljaca: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OdveziDobavljaca uklanja vezu artikal–dobavljač
|
||||
func (r *ArtikalRepo) OdveziDobavljaca(ctx context.Context, artikalID, dobavljacID int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"DELETE FROM artikal_dobavljac WHERE artikal_id = ? AND dobavljac_id = ?", artikalID, dobavljacID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.OdveziDobavljaca: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SveDobavljaceArtikala vraća mapu artikal_id → lista dobavljac_id
|
||||
func (r *ArtikalRepo) SveDobavljaceArtikala(ctx context.Context) (map[int64][]int64, error) {
|
||||
redovi, err := r.db.QueryContext(ctx,
|
||||
"SELECT artikal_id, dobavljac_id FROM artikal_dobavljac ORDER BY artikal_id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: ArtikalRepo.SveDobavljaceArtikala: %w", err)
|
||||
}
|
||||
defer redovi.Close()
|
||||
|
||||
mapa := make(map[int64][]int64)
|
||||
for redovi.Next() {
|
||||
var aid, did int64
|
||||
if err := redovi.Scan(&aid, &did); err != nil {
|
||||
return nil, fmt.Errorf("ntech: ArtikalRepo.SveDobavljaceArtikala: scan: %w", err)
|
||||
}
|
||||
mapa[aid] = append(mapa[aid], did)
|
||||
}
|
||||
return mapa, nil
|
||||
}
|
||||
|
||||
// KorigujKolicinu postavlja novu količinu i upisuje korekciju u magacinske_promene
|
||||
func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var staraCena float64
|
||||
var staraKolicina int
|
||||
err = tx.QueryRowContext(ctx, "SELECT kolicina, nabavna_cena FROM artikli WHERE id = ?", artikalID).
|
||||
Scan(&staraKolicina, &staraCena)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: dohvati: %w", err)
|
||||
}
|
||||
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE artikli SET kolicina = ? WHERE id = ?", novaKolicina, artikalID); err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: update: %w", err)
|
||||
}
|
||||
|
||||
promena := novaKolicina - staraKolicina
|
||||
if err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaKorekcija, promena,
|
||||
staraKolicina, novaKolicina, 0, korisnikID, napomena); err != nil {
|
||||
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// PrebrojiPoFilteru vraća ukupan broj artikala koji zadovoljavaju filter (bez LIMIT/OFFSET)
|
||||
func (r *ArtikalRepo) PrebrojiPoFilteru(ctx context.Context, filter db.ArtikalFilter) (int, error) {
|
||||
upit := `
|
||||
SELECT COUNT(*)
|
||||
FROM artikli a
|
||||
LEFT JOIN kategorije k ON a.kategorija_id = k.id
|
||||
WHERE 1=1`
|
||||
|
||||
args := []any{}
|
||||
|
||||
if filter.Pretraga != "" {
|
||||
upit += " AND (a.naziv LIKE ? OR a.sifra LIKE ? OR a.barkod LIKE ? OR a.lokacija LIKE ? OR k.naziv LIKE ?)"
|
||||
t := "%" + filter.Pretraga + "%"
|
||||
args = append(args, t, t, t, t, t)
|
||||
}
|
||||
|
||||
if filter.KategorijaID != nil {
|
||||
upit += " AND a.kategorija_id = ?"
|
||||
args = append(args, *filter.KategorijaID)
|
||||
}
|
||||
|
||||
if filter.SamoKriticni {
|
||||
upit += " AND (a.tip = 'proizvod' OR a.tip = '') AND a.kolicina <= a.kolicina_min"
|
||||
}
|
||||
|
||||
if filter.Arhivirani {
|
||||
upit += " AND a.arhiviran = 1"
|
||||
} else {
|
||||
upit += " AND a.arhiviran = 0"
|
||||
}
|
||||
|
||||
var broj int
|
||||
if err := r.db.QueryRowContext(ctx, upit, args...).Scan(&broj); err != nil {
|
||||
return 0, fmt.Errorf("ntech: ArtikalRepo.PrebrojiPoFilteru: %w", err)
|
||||
}
|
||||
|
||||
return broj, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
@@ -147,6 +148,7 @@ func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.M
|
||||
SELECT substr(datum_zavrsetka, 1, 7), SUM(cena_konacna)
|
||||
FROM servisni_nalozi
|
||||
WHERE datum_zavrsetka IS NOT NULL
|
||||
AND status = 'Preuzeto'
|
||||
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
|
||||
GROUP BY substr(datum_zavrsetka, 1, 7)`, "MesecniPrihodServis")
|
||||
}
|
||||
@@ -232,3 +234,59 @@ func (r *sqliteIzvestajRepo) TopKlijenti(ctx context.Context, limit int) ([]mode
|
||||
}
|
||||
return lista, rows.Err()
|
||||
}
|
||||
|
||||
// PrometniList vraća sve magacinske promene u zadatom periodu
|
||||
func (r *sqliteIzvestajRepo) PrometniList(ctx context.Context, od, do time.Time) ([]model.PrometniRed, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT mp.datum, a.naziv, COALESCE(a.sifra, ''), mp.tip_promene,
|
||||
mp.promena_kolicine, mp.stanje_pre, mp.stanje_posle,
|
||||
COALESCE(mp.napomena, '')
|
||||
FROM magacinske_promene mp
|
||||
JOIN artikli a ON a.id = mp.artikal_id
|
||||
WHERE DATE(mp.datum) >= DATE(?) AND DATE(mp.datum) <= DATE(?)
|
||||
ORDER BY mp.datum ASC`,
|
||||
od.Format("2006-01-02"), do.Format("2006-01-02"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: IzvestajRepo.PrometniList: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lista []model.PrometniRed
|
||||
for rows.Next() {
|
||||
var p model.PrometniRed
|
||||
if err := rows.Scan(&p.Datum, &p.ArtikalNaziv, &p.ArtikalSifra, &p.TipPromene,
|
||||
&p.PromenaKolicine, &p.StanjePre, &p.StanjePosle, &p.Napomena); err != nil {
|
||||
return nil, fmt.Errorf("ntech: IzvestajRepo.PrometniList: scan: %w", err)
|
||||
}
|
||||
lista = append(lista, p)
|
||||
}
|
||||
return lista, rows.Err()
|
||||
}
|
||||
|
||||
// StanjeZaliha vraća trenutno stanje svih artikala sa vrednostima
|
||||
func (r *sqliteIzvestajRepo) StanjeZaliha(ctx context.Context) ([]model.StanjeZalihaRed, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT a.naziv, COALESCE(a.sifra, ''), COALESCE(k.naziv, ''),
|
||||
a.kolicina, a.kolicina_min, a.nabavna_cena, a.prodajna_cena,
|
||||
a.kolicina * a.nabavna_cena AS vrednost
|
||||
FROM artikli a
|
||||
LEFT JOIN kategorije k ON k.id = a.kategorija_id
|
||||
ORDER BY k.naziv ASC, a.naziv ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lista []model.StanjeZalihaRed
|
||||
for rows.Next() {
|
||||
var s model.StanjeZalihaRed
|
||||
if err := rows.Scan(&s.Naziv, &s.Sifra, &s.Kategorija,
|
||||
&s.Kolicina, &s.KolicinMin, &s.NabavnaCena, &s.ProdajnaCena, &s.VrednostZalihe); err != nil {
|
||||
return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: scan: %w", err)
|
||||
}
|
||||
lista = append(lista, s)
|
||||
}
|
||||
return lista, rows.Err()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func NovaKategorijaRepo(db *sql.DB) *KategorijaRepo {
|
||||
|
||||
// Lista vraća sve kategorije
|
||||
func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) {
|
||||
redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis, marza FROM kategorije ORDER BY naziv ASC")
|
||||
redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis, kod, marza FROM kategorije ORDER BY naziv ASC")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: %w", err)
|
||||
}
|
||||
@@ -29,14 +29,17 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
|
||||
var rezultat []model.Kategorija
|
||||
for redovi.Next() {
|
||||
var k model.Kategorija
|
||||
var opis sql.NullString
|
||||
var opis, kod sql.NullString
|
||||
var marza sql.NullFloat64
|
||||
if err := redovi.Scan(&k.ID, &k.Naziv, &opis, &marza); err != nil {
|
||||
if err := redovi.Scan(&k.ID, &k.Naziv, &opis, &kod, &marza); err != nil {
|
||||
return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: scan: %w", err)
|
||||
}
|
||||
if opis.Valid {
|
||||
k.Opis = opis.String
|
||||
}
|
||||
if kod.Valid {
|
||||
k.Kod = kod.String
|
||||
}
|
||||
if marza.Valid {
|
||||
k.Marza = &marza.Float64
|
||||
}
|
||||
@@ -48,9 +51,13 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
|
||||
|
||||
// Kreiraj dodaje novu kategoriju
|
||||
func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) {
|
||||
var kod any
|
||||
if k.Kod != "" {
|
||||
kod = k.Kod
|
||||
}
|
||||
rezultat, err := r.db.ExecContext(ctx,
|
||||
"INSERT INTO kategorije (naziv, opis, marza) VALUES (?, ?, ?)",
|
||||
k.Naziv, k.Opis, k.Marza,
|
||||
"INSERT INTO kategorije (naziv, opis, kod, marza) VALUES (?, ?, ?, ?)",
|
||||
k.Naziv, k.Opis, kod, k.Marza,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err)
|
||||
@@ -67,17 +74,20 @@ func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int6
|
||||
// DohvatiID vraća jednu kategoriju po ID-u
|
||||
func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) {
|
||||
var k model.Kategorija
|
||||
var opis sql.NullString
|
||||
var opis, kod sql.NullString
|
||||
var marza sql.NullFloat64
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
"SELECT id, naziv, opis, marza FROM kategorije WHERE id = ?", id).
|
||||
Scan(&k.ID, &k.Naziv, &opis, &marza)
|
||||
"SELECT id, naziv, opis, kod, marza FROM kategorije WHERE id = ?", id).
|
||||
Scan(&k.ID, &k.Naziv, &opis, &kod, &marza)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err)
|
||||
}
|
||||
if opis.Valid {
|
||||
k.Opis = opis.String
|
||||
}
|
||||
if kod.Valid {
|
||||
k.Kod = kod.String
|
||||
}
|
||||
if marza.Valid {
|
||||
k.Marza = &marza.Float64
|
||||
}
|
||||
@@ -86,9 +96,13 @@ func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Katego
|
||||
|
||||
// Izmeni ažurira naziv, opis i maržu postojeće kategorije
|
||||
func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error {
|
||||
var kod any
|
||||
if k.Kod != "" {
|
||||
kod = k.Kod
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"UPDATE kategorije SET naziv = ?, opis = ?, marza = ? WHERE id = ?",
|
||||
k.Naziv, k.Opis, k.Marza, k.ID,
|
||||
"UPDATE kategorije SET naziv = ?, opis = ?, kod = ?, marza = ? WHERE id = ?",
|
||||
k.Naziv, k.Opis, kod, k.Marza, k.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
@@ -28,9 +29,9 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
|
||||
args := []any{}
|
||||
|
||||
if pretraga != "" {
|
||||
upit += " AND (ime LIKE ? OR prezime LIKE ? OR naziv_firme LIKE ?)"
|
||||
p := "%" + pretraga + "%"
|
||||
args = append(args, p, p, p)
|
||||
upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)"
|
||||
p := "%" + pretraga + "%"
|
||||
args = append(args, p, p, p, p, p, p)
|
||||
}
|
||||
|
||||
upit += " ORDER BY datum_unosa DESC"
|
||||
@@ -138,6 +139,89 @@ func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListaFilter vraća listu klijenata sa limitom i offsetom (paginacija)
|
||||
func (r *KlijentRepo) ListaFilter(ctx context.Context, filter db.KlijentFilter) ([]model.Klijent, error) {
|
||||
upit := `
|
||||
SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa
|
||||
FROM klijenti
|
||||
WHERE 1=1`
|
||||
|
||||
args := []any{}
|
||||
|
||||
if filter.Pretraga != "" {
|
||||
upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)"
|
||||
p := "%" + filter.Pretraga + "%"
|
||||
args = append(args, p, p, p, p, p, p)
|
||||
}
|
||||
if filter.Tip == "fizicko" || filter.Tip == "pravno" {
|
||||
upit += " AND tip = ?"
|
||||
args = append(args, filter.Tip)
|
||||
}
|
||||
|
||||
upit += " ORDER BY datum_unosa DESC"
|
||||
|
||||
if filter.Limit > 0 {
|
||||
upit += " LIMIT ?"
|
||||
args = append(args, filter.Limit)
|
||||
if filter.Offset > 0 {
|
||||
upit += " OFFSET ?"
|
||||
args = append(args, filter.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
redovi, err := r.db.QueryContext(ctx, upit, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: KlijentRepo.ListaFilter: %w", err)
|
||||
}
|
||||
defer redovi.Close()
|
||||
|
||||
var rezultat []model.Klijent
|
||||
for redovi.Next() {
|
||||
var k model.Klijent
|
||||
var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString
|
||||
err := redovi.Scan(
|
||||
&k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: KlijentRepo.ListaFilter: scan: %w", err)
|
||||
}
|
||||
k.Ime = ime.String
|
||||
k.Prezime = prezime.String
|
||||
k.JMBG = jmbg.String
|
||||
k.NazivFirme = nazivFirme.String
|
||||
k.PIB = pib.String
|
||||
k.Telefon = telefon.String
|
||||
k.Email = email.String
|
||||
k.Mesto = mesto.String
|
||||
k.Napomena = napomena.String
|
||||
rezultat = append(rezultat, k)
|
||||
}
|
||||
|
||||
return rezultat, nil
|
||||
}
|
||||
|
||||
// PrebrojiPoFilteru vraća broj klijenata koji zadovoljavaju filter
|
||||
func (r *KlijentRepo) PrebrojiPoFilteru(ctx context.Context, filter db.KlijentFilter) (int, error) {
|
||||
upit := `SELECT COUNT(*) FROM klijenti WHERE 1=1`
|
||||
args := []any{}
|
||||
|
||||
if filter.Pretraga != "" {
|
||||
upit += " AND (ime LIKE ? OR prezime LIKE ? OR (ime || ' ' || prezime) LIKE ? OR naziv_firme LIKE ? OR telefon LIKE ? OR email LIKE ?)"
|
||||
p := "%" + filter.Pretraga + "%"
|
||||
args = append(args, p, p, p, p, p, p)
|
||||
}
|
||||
if filter.Tip == "fizicko" || filter.Tip == "pravno" {
|
||||
upit += " AND tip = ?"
|
||||
args = append(args, filter.Tip)
|
||||
}
|
||||
|
||||
var broj int
|
||||
if err := r.db.QueryRowContext(ctx, upit, args...).Scan(&broj); err != nil {
|
||||
return 0, fmt.Errorf("ntech: KlijentRepo.PrebrojiPoFilteru: %w", err)
|
||||
}
|
||||
return broj, nil
|
||||
}
|
||||
|
||||
// Obrisi briše klijenta po ID-u
|
||||
func (r *KlijentRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM klijenti WHERE id = ?", id)
|
||||
|
||||
@@ -30,6 +30,9 @@ type korisnikOpcije struct {
|
||||
lokalnaPozadinaBlurPozadine sql.NullString
|
||||
lokalnaPozadinaGlassOpacity sql.NullString
|
||||
avatarPutanja sql.NullString
|
||||
lokalnaAnimacija sql.NullString
|
||||
lokalniHover sql.NullString
|
||||
lokalnaBrzinaAnimacije sql.NullString
|
||||
}
|
||||
|
||||
// dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik
|
||||
@@ -44,6 +47,9 @@ func dodeliOpcijeKorisnika(k *model.Korisnik, o korisnikOpcije) {
|
||||
k.LokalnaPozadinaBlurPozadine = o.lokalnaPozadinaBlurPozadine.String
|
||||
k.LokalnaPozadinaGlassOpacity = o.lokalnaPozadinaGlassOpacity.String
|
||||
k.AvatarPutanja = o.avatarPutanja.String
|
||||
k.LokalnaAnimacija = o.lokalnaAnimacija.String
|
||||
k.LokalniHover = o.lokalniHover.String
|
||||
k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String
|
||||
}
|
||||
|
||||
// skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik
|
||||
@@ -55,6 +61,7 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er
|
||||
&o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
|
||||
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
|
||||
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
|
||||
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -98,7 +105,9 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme
|
||||
COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja,
|
||||
COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'),
|
||||
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '')
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
|
||||
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
|
||||
COALESCE(lokalna_brzina_animacije, '')
|
||||
FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme)
|
||||
k, err := skeniraiKorisnika(row)
|
||||
if err != nil {
|
||||
@@ -114,7 +123,9 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model
|
||||
COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja,
|
||||
COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'),
|
||||
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '')
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
|
||||
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
|
||||
COALESCE(lokalna_brzina_animacije, '')
|
||||
FROM korisnici WHERE id = ?`, id)
|
||||
k, err := skeniraiKorisnika(row)
|
||||
if err != nil {
|
||||
@@ -130,7 +141,9 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
|
||||
COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja,
|
||||
COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'),
|
||||
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '')
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
|
||||
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
|
||||
COALESCE(lokalna_brzina_animacije, '')
|
||||
FROM korisnici ORDER BY datum_kreiranja ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
|
||||
@@ -145,6 +158,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
|
||||
&o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
|
||||
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
|
||||
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
|
||||
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
|
||||
}
|
||||
@@ -194,6 +208,45 @@ func (r *sqliteKorisniciRepo) SacuvajLokalnuTemu(ctx context.Context, id int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error {
|
||||
var val any
|
||||
if animacija != "" {
|
||||
val = animacija
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE korisnici SET lokalna_animacija = ? WHERE id = ?`, val, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.SacuvajLokalnuAnimaciju: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) SacuvajLokalniHover(ctx context.Context, id int64, hover string) error {
|
||||
var val any
|
||||
if hover != "" {
|
||||
val = hover
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE korisnici SET lokalni_hover = ? WHERE id = ?`, val, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.SacuvajLokalniHover: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) SacuvajLokalnuBrzinuAnimacije(ctx context.Context, id int64, brzina string) error {
|
||||
var val any
|
||||
if brzina != "" {
|
||||
val = brzina
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE korisnici SET lokalna_brzina_animacije = ? WHERE id = ?`, val, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.SacuvajLokalnuBrzinuAnimacije: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) AzurirajUlogu(ctx context.Context, id int64, uloga string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -123,7 +123,7 @@ func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniN
|
||||
func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
|
||||
redovi, err := r.db.QueryContext(ctx, `
|
||||
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno,
|
||||
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv
|
||||
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv, a.jedinica_mere
|
||||
FROM stavke_prodaje sp
|
||||
JOIN artikli a ON a.id = sp.artikal_id
|
||||
WHERE sp.nalog_id = ?
|
||||
@@ -140,7 +140,7 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model
|
||||
&s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
|
||||
&s.CenaPoKomadu, &s.Ukupno,
|
||||
&s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv,
|
||||
&s.ArtikalNaziv,
|
||||
&s.ArtikalNaziv, &s.JedinicaMere,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err)
|
||||
@@ -182,25 +182,29 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
||||
|
||||
// provera stanja, smanjenje i insert stavki
|
||||
for _, s := range stavke {
|
||||
var naziv string
|
||||
var naziv, tip string
|
||||
var stanjePre int
|
||||
err := tx.QueryRowContext(ctx,
|
||||
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID,
|
||||
).Scan(&naziv, &stanjePre)
|
||||
"SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID,
|
||||
).Scan(&naziv, &stanjePre, &tip)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
|
||||
}
|
||||
|
||||
if stanjePre < s.Kolicina {
|
||||
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
|
||||
}
|
||||
|
||||
stanjePosle := stanjePre - s.Kolicina
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
|
||||
// usluge i troškovi ne prate lager — ne proveravaju se i ne umanjuju
|
||||
pratiLager := tip == model.TipProizvod || tip == ""
|
||||
stanjePosle := stanjePre
|
||||
if pratiLager {
|
||||
if stanjePre < s.Kolicina {
|
||||
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
|
||||
}
|
||||
stanjePosle = stanjePre - s.Kolicina
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PDV računamo iz cene ako nije eksplicitno postavljeno
|
||||
@@ -223,10 +227,13 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
|
||||
}
|
||||
|
||||
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
|
||||
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err)
|
||||
// magacinsku promenu beležimo samo za artikle koji prate lager
|
||||
if pratiLager {
|
||||
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
|
||||
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,11 +286,17 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
|
||||
|
||||
for _, p := range stavke {
|
||||
var stanjePre int
|
||||
err := tx.QueryRowContext(ctx, "SELECT kolicina FROM artikli WHERE id = ?", p.artikalID).Scan(&stanjePre)
|
||||
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,
|
||||
@@ -315,7 +328,7 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
|
||||
}
|
||||
|
||||
// Obrisi briše prodajni nalog i vraća količine artikala na stanje (u transakciji)
|
||||
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: begin tx: %w", err)
|
||||
@@ -352,13 +365,32 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
redovi.Close()
|
||||
|
||||
for _, p := range stavke {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?",
|
||||
p.kolicina, p.artikalID,
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,24 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
// generisiJavniToken kreira 32-znakovni hex token za javni URL
|
||||
func generisiJavniToken() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// ServisRepo je SQLite implementacija ServisRepository interfejsa
|
||||
type ServisRepo struct {
|
||||
db *sql.DB
|
||||
@@ -43,6 +54,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
|
||||
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
|
||||
sn.opis_kvara, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
|
||||
sn.avans, sn.napomena, sn.garancija_do, sn.datum_prijema, sn.datum_zavrsetka,
|
||||
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.javni_token,
|
||||
COALESCE(kp.naziv, '') AS klijent_naziv
|
||||
FROM servisni_nalozi sn
|
||||
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
|
||||
@@ -88,7 +100,8 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
|
||||
SELECT
|
||||
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
|
||||
opis_kvara, status, cena_od, cena_do, cena_konacna,
|
||||
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka
|
||||
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka,
|
||||
ostecenja, pin_uredjaja, pribor, javni_token
|
||||
FROM servisni_nalozi WHERE id = ?`, id)
|
||||
|
||||
var n model.ServisniNalog
|
||||
@@ -100,18 +113,26 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
// Kreiraj upisuje novi servisni nalog u bazu
|
||||
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token
|
||||
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
|
||||
token, err := generisiJavniToken()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: token: %w", err)
|
||||
}
|
||||
|
||||
rezultat, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO servisni_nalozi
|
||||
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara,
|
||||
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka,
|
||||
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
|
||||
nullString(n.SerijskiBroj), n.OpisKvara, n.Status,
|
||||
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
|
||||
nullFloat64(n.Avans), nullString(n.Napomena),
|
||||
nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
|
||||
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
|
||||
n.DatumPrijema, token,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err)
|
||||
@@ -125,17 +146,36 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// DohvatiJavniToken vraća servisni nalog po javnom tokenu — bez autentifikacije
|
||||
func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*model.ServisniNalog, error) {
|
||||
red := r.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
|
||||
opis_kvara, status, cena_od, cena_do, cena_konacna,
|
||||
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka,
|
||||
ostecenja, pin_uredjaja, pribor, javni_token
|
||||
FROM servisni_nalozi WHERE javni_token = ?`, token)
|
||||
|
||||
var n model.ServisniNalog
|
||||
if err := scanNalog(red.Scan, &n, nil); err != nil {
|
||||
return nil, fmt.Errorf("ntech: ServisRepo.DohvatiJavniToken: %w", err)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
// Izmeni ažurira postojeći servisni nalog — broj_naloga i datum_prijema se ne menjaju
|
||||
func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE servisni_nalozi SET
|
||||
klijent_id = ?, tehnicar_id = ?, uredjaj = ?, serijski_broj = ?, opis_kvara = ?,
|
||||
status = ?, cena_od = ?, cena_do = ?, cena_konacna = ?,
|
||||
avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?
|
||||
avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?,
|
||||
ostecenja = ?, pin_uredjaja = ?, pribor = ?
|
||||
WHERE id = ?`,
|
||||
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara,
|
||||
n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
|
||||
nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
|
||||
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
|
||||
n.ID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -145,6 +185,24 @@ func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AzurirajStatus menja samo status naloga; ako nalog prelazi u završno stanje
|
||||
// i datum_zavrsetka još nije postavljen, automatski ga postavlja na danas.
|
||||
func (r *ServisRepo) AzurirajStatus(ctx context.Context, id int64, status string) error {
|
||||
var upit string
|
||||
if status == model.StatusZavrseno || status == model.StatusPreuzeto {
|
||||
upit = `UPDATE servisni_nalozi SET status = ?,
|
||||
datum_zavrsetka = COALESCE(datum_zavrsetka, date('now', 'localtime'))
|
||||
WHERE id = ?`
|
||||
} else {
|
||||
upit = `UPDATE servisni_nalozi SET status = ? WHERE id = ?`
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, upit, status, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.AzurirajStatus: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Obrisi briše servisni nalog po ID-u
|
||||
func (r *ServisRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM servisni_nalozi WHERE id = ?", id)
|
||||
@@ -159,7 +217,7 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
|
||||
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error {
|
||||
var klijentID, tehnicarID sql.NullInt64
|
||||
var serijskiBroj, napomena sql.NullString
|
||||
var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, javniToken sql.NullString
|
||||
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
|
||||
var garancijaDo, datumZavrsetka sql.NullTime
|
||||
|
||||
@@ -167,6 +225,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
|
||||
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
|
||||
&n.OpisKvara, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
|
||||
&avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka,
|
||||
&ostecenja, &pinUredjaja, &pribor, &javniToken,
|
||||
}
|
||||
|
||||
if klijentNaziv != nil {
|
||||
@@ -187,6 +246,9 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
|
||||
}
|
||||
n.SerijskiBroj = serijskiBroj.String
|
||||
n.Napomena = napomena.String
|
||||
n.Ostecenja = ostecenja.String
|
||||
n.PinUredjaja = pinUredjaja.String
|
||||
n.Pribor = pribor.String
|
||||
if cenaOd.Valid {
|
||||
v := cenaOd.Float64
|
||||
n.CenaOd = &v
|
||||
@@ -211,6 +273,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
|
||||
v := datumZavrsetka.Time
|
||||
n.DatumZavrsetka = &v
|
||||
}
|
||||
n.JavniToken = javniToken.String
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+28
-18
@@ -35,6 +35,7 @@ type podaciAdminProfil struct {
|
||||
BrojRezervnih int // koliko neiskorišćenih rezervnih kodova je preostalo
|
||||
LokalnaTema string
|
||||
KoristiLokalnuTemu bool
|
||||
JelDemo bool
|
||||
}
|
||||
|
||||
type podaciProfilTema struct {
|
||||
@@ -46,6 +47,8 @@ type podaciProfilTema struct {
|
||||
LokalnaPozadinaBlur string
|
||||
LokalnaPozadinaBlurPozadine string
|
||||
LokalnaPozadinaGlassOpacity string
|
||||
LokalnaAnimacija string
|
||||
LokalniHover string
|
||||
}
|
||||
|
||||
// AdminKorisnici prikazuje listu korisnika
|
||||
@@ -123,8 +126,7 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Korisnik je uspešno kreiran.")
|
||||
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminToggleAktivan menja aktivan status korisnika
|
||||
@@ -169,8 +171,7 @@ func (h *Handler) AdminToggleAktivan(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
|
||||
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminPromeniUlogu menja ulogu korisnika
|
||||
@@ -231,8 +232,7 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
|
||||
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminObrisiKorisnika briše korisnika sa ulogom radnik
|
||||
@@ -276,8 +276,7 @@ func (h *Handler) AdminObrisiKorisnika(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Korisnik je obrisan.")
|
||||
http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminProfil prikazuje stranicu profila
|
||||
@@ -310,6 +309,7 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
|
||||
BrojRezervnih: brojRezervnih,
|
||||
LokalnaTema: svezi.LokalnaTema,
|
||||
KoristiLokalnuTemu: svezi.KoristiLokalnuTemu,
|
||||
JelDemo: h.JelDemo,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ func (h *Handler) generisiIPrikaziKodove(w http.ResponseWriter, r *http.Request,
|
||||
TotpAktivan: true,
|
||||
RezervniKodovi: kodovi,
|
||||
BrojRezervnih: len(kodovi),
|
||||
JelDemo: h.JelDemo,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -361,6 +362,12 @@ func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.JelDemo {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Promena lozinke nije dozvoljena u demo modu.")
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
@@ -395,8 +402,7 @@ func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Lozinka je uspešno promenjena.")
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod
|
||||
@@ -407,6 +413,12 @@ func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.JelDemo {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Podešavanje 2FA nije dostupno u demo modu.")
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
totp, err := auth.GenerisuTotpTajnu(k.KorisnickoIme, podesavanja["naziv_firme"])
|
||||
if err != nil {
|
||||
@@ -424,6 +436,7 @@ func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) {
|
||||
TotpURI: totp.URI,
|
||||
TotpTajna: totp.Tajna,
|
||||
TotpQR: template.URL("data:image/png;base64," + totp.QRBase64),
|
||||
JelDemo: h.JelDemo,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -463,6 +476,7 @@ func (h *Handler) AdminTotpAktivacija(w http.ResponseWriter, r *http.Request) {
|
||||
TotpTajna: tajna,
|
||||
TotpQR: template.URL("data:image/png;base64," + qr),
|
||||
Greska: "totp",
|
||||
JelDemo: h.JelDemo,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -510,8 +524,7 @@ func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request)
|
||||
// isključenjem 2FA brišemo i rezervne kodove
|
||||
_ = h.RezervniKodoviRepo.Obrisi(r.Context(), k.ID)
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je isključena.")
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminLoginIstorija prikazuje evidenciju prijava za datog korisnika
|
||||
@@ -660,8 +673,7 @@ func (h *Handler) AdminDozvolePromeniUlogu(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
|
||||
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminDozvoleSacuvaj prima POST i čuva promene dozvola za radnik i admin uloge
|
||||
@@ -692,8 +704,7 @@ func (h *Handler) AdminDozvoleSacuvaj(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Promene su uspešno sačuvane.")
|
||||
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminDozvoleReset vraća sve dozvole na podrazumevane vrednosti — samo superadmin
|
||||
@@ -708,6 +719,5 @@ func (h *Handler) AdminDozvoleReset(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Dozvole su vraćene na podrazumevane vrednosti.")
|
||||
http.Redirect(w, r, "/admin/dozvole", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: os.Getenv("NTECH_ENV") == "production",
|
||||
Secure: os.Getenv("NTECH_ENV") == "production" || os.Getenv("NTECH_ENV") == "demo",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ type Handler struct {
|
||||
PdvKprRepo db.PdvKprRepository
|
||||
NivelacijaRepo db.NivelacijaRepository
|
||||
Verzija string
|
||||
JelDemo bool
|
||||
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
||||
Templates map[string]*template.Template
|
||||
TemplatesFS fs.FS
|
||||
@@ -211,6 +212,11 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
|
||||
ps.AppPozadinaGlassOpacity = "10"
|
||||
}
|
||||
}
|
||||
if korisnik != nil {
|
||||
ps.LokalnaAnimacija = korisnik.LokalnaAnimacija
|
||||
ps.LokalniHover = korisnik.LokalniHover
|
||||
ps.LokalnaBrzinaAnimacije = korisnik.LokalnaBrzinaAnimacije
|
||||
}
|
||||
|
||||
return ps
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
appdbPkg "ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
@@ -202,3 +205,187 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
h.renderujTemplate(w, "izvestaji", podaci)
|
||||
}
|
||||
|
||||
// PodaciPrometногLista su podaci za prometni list magacina
|
||||
type PodaciPrometногLista struct {
|
||||
model.PodaciStranice
|
||||
Promene []model.PrometniRed
|
||||
Od string
|
||||
Do string
|
||||
Ukupno int
|
||||
}
|
||||
|
||||
// PrometniListMagacina renderuje prometni list magacina za odabrani period
|
||||
func (h *Handler) PrometniListMagacina(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "izvestaj.pregled"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
danas := time.Now()
|
||||
odStr := r.URL.Query().Get("od")
|
||||
doStr := r.URL.Query().Get("do")
|
||||
|
||||
if odStr == "" {
|
||||
odStr = danas.Format("2006-01-02")[:7] + "-01"
|
||||
}
|
||||
if doStr == "" {
|
||||
doStr = danas.Format("2006-01-02")
|
||||
}
|
||||
|
||||
od, err := time.Parse("2006-01-02", odStr)
|
||||
if err != nil {
|
||||
od = time.Now()
|
||||
}
|
||||
do, err := time.Parse("2006-01-02", doStr)
|
||||
if err != nil {
|
||||
do = time.Now()
|
||||
}
|
||||
|
||||
promene, err := h.IzvestajRepo.PrometniList(r.Context(), od, do)
|
||||
if err != nil {
|
||||
slog.Error("prometni list: greška", "error", err)
|
||||
promene = nil
|
||||
}
|
||||
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "izvestaji"
|
||||
ps.NaslovStranice = "Prometni list"
|
||||
|
||||
h.renderujTemplate(w, "prometni_list", PodaciPrometногLista{
|
||||
PodaciStranice: ps,
|
||||
Promene: promene,
|
||||
Od: odStr,
|
||||
Do: doStr,
|
||||
Ukupno: len(promene),
|
||||
})
|
||||
}
|
||||
|
||||
// PodaciStanjaZaliha su podaci za izveštaj o stanju zaliha
|
||||
type PodaciStanjaZaliha struct {
|
||||
model.PodaciStranice
|
||||
Zalihe []model.StanjeZalihaRed
|
||||
UkupnaVrednost float64
|
||||
BrojArtikala int
|
||||
}
|
||||
|
||||
// StanjeZalihaIzvestaj renderuje izveštaj o trenutnom stanju zaliha
|
||||
func (h *Handler) StanjeZalihaIzvestaj(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "izvestaj.pregled"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
zalihe, err := h.IzvestajRepo.StanjeZaliha(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("stanje zaliha: greška", "error", err)
|
||||
zalihe = nil
|
||||
}
|
||||
|
||||
var ukupnaVrednost float64
|
||||
for _, z := range zalihe {
|
||||
ukupnaVrednost += z.VrednostZalihe
|
||||
}
|
||||
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "izvestaji"
|
||||
ps.NaslovStranice = "Stanje zaliha"
|
||||
|
||||
h.renderujTemplate(w, "stanje_zaliha", PodaciStanjaZaliha{
|
||||
PodaciStranice: ps,
|
||||
Zalihe: zalihe,
|
||||
UkupnaVrednost: ukupnaVrednost,
|
||||
BrojArtikala: len(zalihe),
|
||||
})
|
||||
}
|
||||
|
||||
// PodaciPopisa su podaci za stranicu popisa
|
||||
type PodaciPopisa struct {
|
||||
model.PodaciStranice
|
||||
Artikli []model.ArtikalSaKategorijom
|
||||
Sacuvano bool
|
||||
Greska string
|
||||
}
|
||||
|
||||
// Popis prikazuje formu za unos stvarnog stanja (inventuru)
|
||||
func (h *Handler) Popis(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{})
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "izvestaji"
|
||||
ps.NaslovStranice = "Popis"
|
||||
|
||||
h.renderujTemplate(w, "popis", PodaciPopisa{
|
||||
PodaciStranice: ps,
|
||||
Artikli: artikli,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
})
|
||||
}
|
||||
|
||||
// SacuvajPopis prima POST formu sa prebrojenim količinama i upisuje korekcije
|
||||
func (h *Handler) SacuvajPopis(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "artikal.izmeni") {
|
||||
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{})
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
napomena := r.FormValue("napomena")
|
||||
if napomena == "" {
|
||||
napomena = "Godišnji popis"
|
||||
}
|
||||
|
||||
var greskaBroj int
|
||||
for _, a := range artikli {
|
||||
kljuc := fmt.Sprintf("kolicina_%d", a.ID)
|
||||
vr := r.FormValue(kljuc)
|
||||
if vr == "" {
|
||||
continue
|
||||
}
|
||||
nova, err := strconv.Atoi(vr)
|
||||
if err != nil || nova < 0 {
|
||||
continue
|
||||
}
|
||||
if nova == a.Kolicina {
|
||||
continue
|
||||
}
|
||||
if err := h.Artikli.KorigujKolicinu(r.Context(), a.ID, nova, &k.ID, napomena); err != nil {
|
||||
slog.Error("popis: korekcija artikla", "id", a.ID, "error", err)
|
||||
greskaBroj++
|
||||
}
|
||||
}
|
||||
|
||||
if greskaBroj > 0 {
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "izvestaji"
|
||||
ps.NaslovStranice = "Popis"
|
||||
h.renderujTemplate(w, "popis", PodaciPopisa{
|
||||
PodaciStranice: ps,
|
||||
Artikli: artikli,
|
||||
Greska: fmt.Sprintf("Došlo je do greške pri upisivanju %d artikala.", greskaBroj),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/izvestaji/popis?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
k := &model.Kategorija{
|
||||
Naziv: naziv,
|
||||
Opis: r.FormValue("opis"),
|
||||
Kod: normalizujKod(r.FormValue("kod")),
|
||||
Marza: parsirajMarzu(r.FormValue("marza")),
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
ID: id,
|
||||
Naziv: naziv,
|
||||
Opis: r.FormValue("opis"),
|
||||
Kod: normalizujKod(r.FormValue("kod")),
|
||||
Marza: parsirajMarzu(r.FormValue("marza")),
|
||||
}
|
||||
|
||||
@@ -126,6 +128,19 @@ func parsirajMarzu(s string) *float64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
// normalizujKod čisti kôd kategorije za upotrebu kao prefiks šifre:
|
||||
// velika slova, zadržava samo slova i brojeve (bez razmaka i specijalnih znakova).
|
||||
func normalizujKod(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ObrisiKategoriju briše kategoriju po ID-u
|
||||
func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok {
|
||||
|
||||
+134
-5
@@ -5,7 +5,9 @@ import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var bazniSabloni = []string{
|
||||
@@ -19,12 +21,12 @@ var saSidebar = []string{
|
||||
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole",
|
||||
"dashboard",
|
||||
"dobavljaci", "dobavljac_forma",
|
||||
"izvestaji",
|
||||
"izvestaji", "prometni_list", "stanje_zaliha", "popis",
|
||||
"kategorije",
|
||||
"klijenti", "klijent_forma",
|
||||
"magacin", "magacin_forma",
|
||||
"magacin", "magacin_forma", "magacin_kartica",
|
||||
"nabavke", "nabavka_forma", "nabavka_detalji",
|
||||
"podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem",
|
||||
"podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podesavanja_servis",
|
||||
"pdv_stope",
|
||||
"pdv_kir", "pdv_kir_forma",
|
||||
"pdv_kpr", "pdv_kpr_forma",
|
||||
@@ -38,13 +40,44 @@ var saSidebar = []string{
|
||||
|
||||
// standalone su šabloni bez base layouta
|
||||
var standaloneIme = []string{
|
||||
"prijava", "setup", "totp_provera", "prodaja_stampa",
|
||||
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_stampa", "servis_otpremnica", "servis_predracun", "servis_status_javni",
|
||||
}
|
||||
|
||||
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
|
||||
// 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)}}).
|
||||
var sablonskeFunkcije = template.FuncMap{
|
||||
// formatBroj formatira float pointer kao ceo broj (zaokružen) — nil vraca ""
|
||||
"formatBroj": func(v *float64) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", int64(math.Round(*v)))
|
||||
},
|
||||
// dinari formatira iznos sa separatorom hiljada (tačka) i 2 decimale (zarez):
|
||||
// 1234567.5 → "1.234.567,50"
|
||||
"dinari": func(v float64) string {
|
||||
return formatirajDinare(v, 2)
|
||||
},
|
||||
// dinariCeli formatira iznos sa separatorom hiljada, bez decimala: 1234567 → "1.234.567"
|
||||
"dinariCeli": func(v float64) string {
|
||||
return formatirajDinare(v, 0)
|
||||
},
|
||||
// telefon formatira srpski broj telefona radi lakšeg čitanja: "0641234567" → "064 123 4567"
|
||||
"telefon": formatirajTelefon,
|
||||
// statusPre vraća true ako je `a` pre `b` u redosledu statusa
|
||||
"statusPre": func(a, b string, statusi []string) bool {
|
||||
ia, ib := -1, -1
|
||||
for i, s := range statusi {
|
||||
if s == a {
|
||||
ia = i
|
||||
}
|
||||
if s == b {
|
||||
ib = i
|
||||
}
|
||||
}
|
||||
return ia >= 0 && ib >= 0 && ia < ib
|
||||
},
|
||||
"dict": func(parovi ...any) (map[string]any, error) {
|
||||
if len(parovi)%2 != 0 {
|
||||
return nil, fmt.Errorf("dict: neparan broj argumenata")
|
||||
@@ -77,7 +110,8 @@ func KreirajKes(fsys fs.FS) (map[string]*template.Template, error) {
|
||||
}
|
||||
|
||||
for _, ime := range standaloneIme {
|
||||
t, err := template.ParseFS(fsys, "web/templates/stranice/"+ime+".html")
|
||||
// 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")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kes: %s: %w", ime, err)
|
||||
}
|
||||
@@ -150,3 +184,98 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a
|
||||
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// formatirajDinare formatira broj sa tačkom kao separatorom hiljada i zarezom
|
||||
// za decimale (srpski format). decimale = broj decimalnih mesta (0 ili 2).
|
||||
func formatirajDinare(v float64, decimale int) string {
|
||||
negativan := v < 0
|
||||
if negativan {
|
||||
v = -v
|
||||
}
|
||||
|
||||
var ceoStr, decStr string
|
||||
if decimale == 2 {
|
||||
// radi u stotinkama da zaokruživanje pravilno prenese (npr. 1234567.999 → 1.234.568,00)
|
||||
stotinke := int64(math.Round(v * 100))
|
||||
ceoStr = fmt.Sprintf("%d", stotinke/100)
|
||||
decStr = fmt.Sprintf("%02d", stotinke%100)
|
||||
} else {
|
||||
ceoStr = fmt.Sprintf("%d", int64(math.Round(v)))
|
||||
}
|
||||
|
||||
// ubaci tačke na svake 3 cifre s desna
|
||||
var sb []byte
|
||||
n := len(ceoStr)
|
||||
for i, c := range ceoStr {
|
||||
if i > 0 && (n-i)%3 == 0 {
|
||||
sb = append(sb, '.')
|
||||
}
|
||||
sb = append(sb, byte(c))
|
||||
}
|
||||
rezultat := string(sb)
|
||||
|
||||
if decimale == 2 {
|
||||
rezultat += "," + decStr
|
||||
}
|
||||
if negativan {
|
||||
rezultat = "-" + rezultat
|
||||
}
|
||||
return rezultat
|
||||
}
|
||||
|
||||
// formatirajTelefon formatira srpski broj telefona radi lakšeg čitanja:
|
||||
// pozivni broj odvojen kosom crtom, ostatak grupisan crticom.
|
||||
// Primeri: "0641234567" → "064/123-4567", "+381641234567" → "+381 64/123-4567".
|
||||
// Ako format nije prepoznat, vraća original.
|
||||
func formatirajTelefon(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// izdvoj cifre i zapamti da li je međunarodni (+)
|
||||
medjunarodni := strings.HasPrefix(s, "+") || strings.HasPrefix(s, "00")
|
||||
var cifre []rune
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
cifre = append(cifre, c)
|
||||
}
|
||||
}
|
||||
d := string(cifre)
|
||||
|
||||
// međunarodni srpski prefiks (381): "+381 64/123-4567"
|
||||
if medjunarodni {
|
||||
d = strings.TrimPrefix(d, "00")
|
||||
if strings.HasPrefix(d, "381") {
|
||||
ostatak := d[3:] // bez vodeće nule, npr. "641234567"
|
||||
if len(ostatak) < 7 || len(ostatak) > 9 {
|
||||
return s
|
||||
}
|
||||
return "+381 " + ostatak[:2] + "/" + grupisiTelefon(ostatak[2:])
|
||||
}
|
||||
return s // strani broj — ne diramo
|
||||
}
|
||||
|
||||
// lokalni format: očekujemo vodeću nulu i 8–10 cifara ukupno
|
||||
if !strings.HasPrefix(d, "0") || len(d) < 8 || len(d) > 10 {
|
||||
return s
|
||||
}
|
||||
// pozivni (3 cifre, npr. 064/011) "/" ostatak grupisan crticom
|
||||
return d[:3] + "/" + grupisiTelefon(d[3:])
|
||||
}
|
||||
|
||||
// grupisiTelefon deli niz cifara u grupe od po 3 crticom (poslednja može 4) — "1234567" → "123-4567"
|
||||
func grupisiTelefon(d string) string {
|
||||
if len(d) <= 4 {
|
||||
return d
|
||||
}
|
||||
var delovi []string
|
||||
delovi = append(delovi, d[:3])
|
||||
ostatak := d[3:]
|
||||
for len(ostatak) > 4 {
|
||||
delovi = append(delovi, ostatak[:3])
|
||||
ostatak = ostatak[3:]
|
||||
}
|
||||
delovi = append(delovi, ostatak)
|
||||
return strings.Join(delovi, "-")
|
||||
}
|
||||
|
||||
+71
-11
@@ -3,8 +3,10 @@ package handler
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/model"
|
||||
|
||||
@@ -14,10 +16,17 @@ import (
|
||||
// PodaciKlijenata su podaci za stranicu sa listom klijenata
|
||||
type PodaciKlijenata struct {
|
||||
model.PodaciStranice
|
||||
Klijenti []model.Klijent
|
||||
Pretraga string
|
||||
Sacuvano bool
|
||||
Obrisan bool
|
||||
Klijenti []model.Klijent
|
||||
Pretraga string
|
||||
TipFilter string
|
||||
Sacuvano bool
|
||||
Obrisan bool
|
||||
StranicaBr int
|
||||
UkupnoStranica int
|
||||
UkupnoKlijenata int
|
||||
StranicaPrev int
|
||||
StranicaNext int
|
||||
StranicaQueryUrl string
|
||||
}
|
||||
|
||||
// PodaciFormeKlijenta su podaci za formu novog/izmenjenog klijenta
|
||||
@@ -28,7 +37,7 @@ type PodaciFormeKlijenta struct {
|
||||
Izmena bool
|
||||
}
|
||||
|
||||
// Klijenti renderuje listu svih klijenata sa opcionom pretragom
|
||||
// Klijenti renderuje listu svih klijenata sa opcionom pretragom i paginacijom
|
||||
func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) {
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
@@ -37,22 +46,73 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
pretraga := r.URL.Query().Get("pretraga")
|
||||
tipFilter := r.URL.Query().Get("tip")
|
||||
if tipFilter != "fizicko" && tipFilter != "pravno" {
|
||||
tipFilter = ""
|
||||
}
|
||||
|
||||
klijenti, err := h.KlijentiRepo.Lista(r.Context(), pretraga)
|
||||
const pageSize = 50
|
||||
stranicaBr := 1
|
||||
if p := r.URL.Query().Get("stranica"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil && v > 0 {
|
||||
stranicaBr = v
|
||||
}
|
||||
}
|
||||
|
||||
filter := db.KlijentFilter{
|
||||
Pretraga: pretraga,
|
||||
Tip: tipFilter,
|
||||
Limit: pageSize,
|
||||
Offset: (stranicaBr - 1) * pageSize,
|
||||
}
|
||||
|
||||
klijenti, err := h.KlijentiRepo.ListaFilter(r.Context(), filter)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju klijenata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ukupno, err := h.KlijentiRepo.PrebrojiPoFilteru(r.Context(), filter)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju klijenata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ukupnoStranica := (ukupno + pageSize - 1) / pageSize
|
||||
|
||||
queryDelići := ""
|
||||
if pretraga != "" {
|
||||
queryDelići += "&pretraga=" + pretraga
|
||||
}
|
||||
if tipFilter != "" {
|
||||
queryDelići += "&tip=" + tipFilter
|
||||
}
|
||||
|
||||
stranicaPrev := stranicaBr - 1
|
||||
if stranicaPrev < 1 {
|
||||
stranicaPrev = 1
|
||||
}
|
||||
stranicaNext := stranicaBr + 1
|
||||
if stranicaNext > ukupnoStranica {
|
||||
stranicaNext = ukupnoStranica
|
||||
}
|
||||
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "klijenti"
|
||||
ps.NaslovStranice = "Klijenti"
|
||||
podaci := PodaciKlijenata{
|
||||
PodaciStranice: ps,
|
||||
Klijenti: klijenti,
|
||||
Pretraga: pretraga,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
Obrisan: r.URL.Query().Get("obrisan") == "1",
|
||||
PodaciStranice: ps,
|
||||
Klijenti: klijenti,
|
||||
Pretraga: pretraga,
|
||||
TipFilter: tipFilter,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
Obrisan: r.URL.Query().Get("obrisan") == "1",
|
||||
StranicaBr: stranicaBr,
|
||||
UkupnoStranica: ukupnoStranica,
|
||||
UkupnoKlijenata: ukupno,
|
||||
StranicaPrev: stranicaPrev,
|
||||
StranicaNext: stranicaNext,
|
||||
StranicaQueryUrl: queryDelići,
|
||||
}
|
||||
|
||||
h.renderujTemplate(w, "klijenti", podaci)
|
||||
|
||||
+210
-16
@@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -14,13 +16,23 @@ import (
|
||||
// PodaciMagacina su podaci za stranicu magacina
|
||||
type PodaciMagacina struct {
|
||||
model.PodaciStranice
|
||||
Artikli []model.ArtikalSaKategorijom
|
||||
Kategorije []model.Kategorija
|
||||
Filter db.ArtikalFilter
|
||||
KategorijaIDStr string
|
||||
Sacuvano bool
|
||||
Obrisan bool
|
||||
Premesten bool
|
||||
Artikli []model.ArtikalSaKategorijom
|
||||
Kategorije []model.Kategorija
|
||||
Filter db.ArtikalFilter
|
||||
KategorijaIDStr string
|
||||
Sacuvano bool
|
||||
Obrisan bool
|
||||
Arhiviran bool
|
||||
Vracen bool
|
||||
Greska bool
|
||||
PrikazArhivirani bool // true → lista prikazuje arhivirane umesto aktivnih
|
||||
Premesten bool
|
||||
StranicaBr int
|
||||
UkupnoStranica int
|
||||
UkupnoArtikala int
|
||||
StranicaPrev int
|
||||
StranicaNext int
|
||||
StranicaQueryUrl string // čuva filtere za linkove paginacije
|
||||
}
|
||||
|
||||
// Magacin renderuje listu artikala
|
||||
@@ -34,6 +46,7 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
||||
filter := db.ArtikalFilter{
|
||||
Pretraga: r.URL.Query().Get("pretraga"),
|
||||
SamoKriticni: r.URL.Query().Get("kriticni") == "1",
|
||||
Arhivirani: r.URL.Query().Get("arhivirani") == "1",
|
||||
}
|
||||
|
||||
katIDStr := ""
|
||||
@@ -45,12 +58,30 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = 50
|
||||
stranicaBr := 1
|
||||
if p := r.URL.Query().Get("stranica"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil && v > 0 {
|
||||
stranicaBr = v
|
||||
}
|
||||
}
|
||||
filter.Limit = pageSize
|
||||
filter.Offset = (stranicaBr - 1) * pageSize
|
||||
|
||||
artikli, err := h.Artikli.Lista(r.Context(), filter)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ukupno, err := h.Artikli.PrebrojiPoFilteru(r.Context(), filter)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ukupnoStranica := (ukupno + pageSize - 1) / pageSize
|
||||
|
||||
kategorije, err := h.KategorijeRepo.Lista(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju kategorija", http.StatusInternalServerError)
|
||||
@@ -60,15 +91,50 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Magacin"
|
||||
|
||||
// izgradi query string za paginaciju (čuva filtere)
|
||||
queryDelići := ""
|
||||
if v := filter.Pretraga; v != "" {
|
||||
queryDelići += "&pretraga=" + v
|
||||
}
|
||||
if katIDStr != "" {
|
||||
queryDelići += "&kategorija=" + katIDStr
|
||||
}
|
||||
if filter.SamoKriticni {
|
||||
queryDelići += "&kriticni=1"
|
||||
}
|
||||
if filter.Arhivirani {
|
||||
queryDelići += "&arhivirani=1"
|
||||
}
|
||||
|
||||
stranicaPrev := stranicaBr - 1
|
||||
if stranicaPrev < 1 {
|
||||
stranicaPrev = 1
|
||||
}
|
||||
stranicaNext := stranicaBr + 1
|
||||
if stranicaNext > ukupnoStranica {
|
||||
stranicaNext = ukupnoStranica
|
||||
}
|
||||
|
||||
podaci := PodaciMagacina{
|
||||
PodaciStranice: ps,
|
||||
Artikli: artikli,
|
||||
Kategorije: kategorije,
|
||||
Filter: filter,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
Obrisan: r.URL.Query().Get("obrisan") == "1",
|
||||
Premesten: r.URL.Query().Get("premesten") == "1",
|
||||
PodaciStranice: ps,
|
||||
Artikli: artikli,
|
||||
Kategorije: kategorije,
|
||||
Filter: filter,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
Obrisan: r.URL.Query().Get("obrisan") == "1",
|
||||
Arhiviran: r.URL.Query().Get("arhiviran") == "1",
|
||||
Vracen: r.URL.Query().Get("vracen") == "1",
|
||||
Greska: r.URL.Query().Get("greska") == "1",
|
||||
PrikazArhivirani: filter.Arhivirani,
|
||||
Premesten: r.URL.Query().Get("premesten") == "1",
|
||||
StranicaBr: stranicaBr,
|
||||
UkupnoStranica: ukupnoStranica,
|
||||
UkupnoArtikala: ukupno,
|
||||
StranicaPrev: stranicaPrev,
|
||||
StranicaNext: stranicaNext,
|
||||
StranicaQueryUrl: queryDelići,
|
||||
}
|
||||
|
||||
h.renderujTemplate(w, "magacin", podaci)
|
||||
@@ -117,9 +183,137 @@ func (h *Handler) ObrisiArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.Artikli.Obrisi(r.Context(), id); err != nil {
|
||||
http.Error(w, "Greška pri brisanju artikla", http.StatusInternalServerError)
|
||||
// artikal je u prometu — ne brišemo ga, već ga arhiviramo
|
||||
if errors.Is(err, db.ErrArtikalUUpotrebi) {
|
||||
if err := h.Artikli.Arhiviraj(r.Context(), id); err != nil {
|
||||
http.Redirect(w, r, "/magacin?greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/magacin?arhiviran=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/magacin?greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/magacin?obrisan=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// VratiArtikal poništava arhiviranje i vraća artikal u aktivnu listu
|
||||
func (h *Handler) VratiArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "artikal.obrisi"); !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Artikli.Vrati(r.Context(), id); err != nil {
|
||||
http.Redirect(w, r, "/magacin?arhivirani=1&greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/magacin?arhivirani=1&vracen=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// PodaciMagacinskeKartice su podaci za karticu jednog artikla
|
||||
type PodaciMagacinskeKartice struct {
|
||||
model.PodaciStranice
|
||||
Artikal model.Artikal
|
||||
Promene []model.MagacinskaPromenaSaDetaljem
|
||||
Dobavljaci []model.Dobavljac // dobavljači vezani za artikal
|
||||
DostupniDobavljaci []model.Dobavljac // dobavljači koji još nisu vezani (za dodavanje)
|
||||
}
|
||||
|
||||
// MagacinskaKartica prikazuje sve promene stanja za jedan artikal
|
||||
func (h *Handler) MagacinskaKartica(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
artikal, err := h.Artikli.DohvatiID(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Artikal nije pronađen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
promene, err := h.MagacinskePromeneRepo.Lista(r.Context(), &id, 0)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju promena", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// dobavljači: vezani za artikal i oni koji još nisu vezani (za padajući izbor)
|
||||
sviDobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||
vezaniIDs, _ := h.Artikli.DobavljaciArtikla(r.Context(), id)
|
||||
vezanSet := map[int64]bool{}
|
||||
for _, did := range vezaniIDs {
|
||||
vezanSet[did] = true
|
||||
}
|
||||
var vezani, dostupni []model.Dobavljac
|
||||
for _, d := range sviDobavljaci {
|
||||
if vezanSet[d.ID] {
|
||||
vezani = append(vezani, d)
|
||||
} else {
|
||||
dostupni = append(dostupni, d)
|
||||
}
|
||||
}
|
||||
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Kartica: " + artikal.Naziv
|
||||
|
||||
h.renderujTemplate(w, "magacin_kartica", PodaciMagacinskeKartice{
|
||||
PodaciStranice: ps,
|
||||
Artikal: *artikal,
|
||||
Promene: promene,
|
||||
Dobavljaci: vezani,
|
||||
DostupniDobavljaci: dostupni,
|
||||
})
|
||||
}
|
||||
|
||||
// DodajDobavljacaArtiklu veže izabranog dobavljača za artikal
|
||||
func (h *Handler) DodajDobavljacaArtiklu(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dobID, err := strconv.ParseInt(r.FormValue("dobavljac_id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/magacin/kartica/"+chi.URLParam(r, "id"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if e := h.Artikli.PoveziDobavljaca(r.Context(), id, dobID); e != nil {
|
||||
slog.Error("vezivanje dobavljača nije uspelo", "artikal_id", id, "error", e)
|
||||
}
|
||||
http.Redirect(w, r, "/magacin/kartica/"+chi.URLParam(r, "id"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ObrisiDobavljacaArtikla uklanja vezu dobavljača sa artiklom
|
||||
func (h *Handler) ObrisiDobavljacaArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID artikla", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dobID, err := strconv.ParseInt(r.FormValue("dobavljac_id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/magacin/kartica/"+chi.URLParam(r, "id"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if e := h.Artikli.OdveziDobavljaca(r.Context(), id, dobID); e != nil {
|
||||
slog.Error("uklanjanje dobavljača nije uspelo", "artikal_id", id, "error", e)
|
||||
}
|
||||
http.Redirect(w, r, "/magacin/kartica/"+chi.URLParam(r, "id"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
@@ -16,11 +18,13 @@ import (
|
||||
// PodaciFormeArtikla su podaci za formu novog/izmenjenog artikla
|
||||
type PodaciFormeArtikla struct {
|
||||
model.PodaciStranice
|
||||
Artikal model.Artikal
|
||||
Kategorije []model.Kategorija
|
||||
KategorijaIDStr string
|
||||
Greska string
|
||||
Izmena bool
|
||||
Artikal model.Artikal
|
||||
Kategorije []model.Kategorija
|
||||
KategorijaIDStr string
|
||||
Dobavljaci []model.Dobavljac // svi dobavljači za izbor
|
||||
IzabraniDobavljaci map[int64]bool // dobavljači vezani za artikal (za checked stanje)
|
||||
Greska string
|
||||
Izmena bool
|
||||
}
|
||||
|
||||
// NoviArtikal prikazuje formu za unos novog artikla
|
||||
@@ -37,12 +41,22 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
predlogSifre, err := h.Artikli.SledecaSifra(r.Context(), nil)
|
||||
if err != nil {
|
||||
slog.Error("greška pri generisanju predloga šifre", "err", err)
|
||||
predlogSifre = "ART-0001"
|
||||
}
|
||||
|
||||
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Novi artikal"
|
||||
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||
PodaciStranice: ps,
|
||||
Kategorije: kategorije,
|
||||
Dobavljaci: dobavljaci,
|
||||
Artikal: model.Artikal{Sifra: predlogSifre, Tip: model.TipProizvod, JedinicaMere: "kom"},
|
||||
Izmena: false,
|
||||
})
|
||||
}
|
||||
@@ -63,6 +77,7 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
if greska != "" {
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||
katIDStr := ""
|
||||
if artikal.KategorijaID != nil {
|
||||
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
|
||||
@@ -71,21 +86,40 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Novi artikal"
|
||||
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||
PodaciStranice: ps,
|
||||
Artikal: artikal,
|
||||
Kategorije: kategorije,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Greska: greska,
|
||||
Izmena: false,
|
||||
PodaciStranice: ps,
|
||||
Artikal: artikal,
|
||||
Kategorije: kategorije,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Dobavljaci: dobavljaci,
|
||||
IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)),
|
||||
Greska: greska,
|
||||
Izmena: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ako korisnik nije uneo šifru, auto-generišemo pre Kreiraj
|
||||
// (tako je dodela šifre atomična: ako INSERT padne na UNIQUE constraint,
|
||||
// Kreiraj vraća grešku umesto da šifra ostane NULL bez ikakve poruke)
|
||||
if artikal.Sifra == "" {
|
||||
autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID)
|
||||
if e != nil {
|
||||
autoSifra = fmt.Sprintf("ART-%04d", time.Now().UnixMilli()%10000)
|
||||
}
|
||||
artikal.Sifra = autoSifra
|
||||
}
|
||||
|
||||
id, err := h.Artikli.Kreiraj(r.Context(), &artikal)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
artikal.ID = id
|
||||
|
||||
// veži izabrane dobavljače (forma); modal nema to polje pa ostaje prazno
|
||||
if e := h.Artikli.PostaviDobavljaceArtikla(r.Context(), id, citajDobavljaceForme(r)); e != nil {
|
||||
slog.Error("čuvanje dobavljača artikla nije uspelo", "artikal_id", id, "error", e)
|
||||
}
|
||||
|
||||
// fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla
|
||||
if r.Header.Get("X-Requested-With") == "fetch" {
|
||||
@@ -129,15 +163,25 @@ func (h *Handler) IzmeniArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
|
||||
}
|
||||
|
||||
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||
izabrani := map[int64]bool{}
|
||||
if ids, e := h.Artikli.DobavljaciArtikla(r.Context(), id); e == nil {
|
||||
for _, did := range ids {
|
||||
izabrani[did] = true
|
||||
}
|
||||
}
|
||||
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Izmeni artikal"
|
||||
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||
PodaciStranice: ps,
|
||||
Artikal: *artikal,
|
||||
Kategorije: kategorije,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Izmena: true,
|
||||
PodaciStranice: ps,
|
||||
Artikal: *artikal,
|
||||
Kategorije: kategorije,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Dobavljaci: dobavljaci,
|
||||
IzabraniDobavljaci: izabrani,
|
||||
Izmena: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,6 +208,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
if greska != "" {
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||
artikal.ID = id
|
||||
katIDStr := ""
|
||||
if artikal.KategorijaID != nil {
|
||||
@@ -173,12 +218,14 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Izmeni artikal"
|
||||
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||
PodaciStranice: ps,
|
||||
Artikal: artikal,
|
||||
Kategorije: kategorije,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Greska: greska,
|
||||
Izmena: true,
|
||||
PodaciStranice: ps,
|
||||
Artikal: artikal,
|
||||
Kategorije: kategorije,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Dobavljaci: dobavljaci,
|
||||
IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)),
|
||||
Greska: greska,
|
||||
Izmena: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -187,6 +234,13 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
var staraCena float64
|
||||
if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil {
|
||||
staraCena = stari.ProdajnaCena
|
||||
// spreči promenu tipa sa proizvoda na uslugu/trošak ako artikal ima zalihu
|
||||
if stari.PratiLager() && stari.Kolicina > 0 && !artikal.PratiLager() {
|
||||
middleware.SetFlash(w, r, h.DB, "greska",
|
||||
fmt.Sprintf("Artikal ima %d %s na stanju. Prvo koriguj količinu na 0 pre promene tipa.", stari.Kolicina, stari.JedinicaMere))
|
||||
http.Redirect(w, r, "/magacin/izmeni/"+idStr, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
artikal.ID = id
|
||||
@@ -195,6 +249,11 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ažuriraj dobavljače artikla prema formi
|
||||
if e := h.Artikli.PostaviDobavljaceArtikla(r.Context(), id, citajDobavljaceForme(r)); e != nil {
|
||||
slog.Error("čuvanje dobavljača artikla nije uspelo", "artikal_id", id, "error", e)
|
||||
}
|
||||
|
||||
// ako se prodajna cena promenila, automatski upiši nivelacioni zapis (izvor "izmena")
|
||||
if razlika := artikal.ProdajnaCena - staraCena; razlika > 0.005 || razlika < -0.005 {
|
||||
korisnikID := &k.ID
|
||||
@@ -212,6 +271,26 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// mapaDobavljaca pretvara listu ID-jeva u mapu za checked stanje u formi
|
||||
func mapaDobavljaca(ids []int64) map[int64]bool {
|
||||
m := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
m[id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// citajDobavljaceForme čita izabrane dobavljače (checkbox dobavljaci[]) iz forme
|
||||
func citajDobavljaceForme(r *http.Request) []int64 {
|
||||
var ids []int64
|
||||
for _, v := range r.Form["dobavljaci"] {
|
||||
if id, e := strconv.ParseInt(strings.TrimSpace(v), 10, 64); e == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// parseFormuArtikla čita polja iz forme i vraća artikal i eventualnu grešku
|
||||
func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
naziv := r.FormValue("naziv")
|
||||
@@ -221,10 +300,25 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
|
||||
var artikal model.Artikal
|
||||
artikal.Naziv = naziv
|
||||
artikal.Sifra = r.FormValue("sifra")
|
||||
artikal.Barkod = r.FormValue("barkod")
|
||||
artikal.Opis = r.FormValue("opis")
|
||||
artikal.Lokacija = r.FormValue("lokacija")
|
||||
artikal.Napomena = r.FormValue("napomena")
|
||||
|
||||
// tip artikla — podrazumevano proizvod; usluga i trošak ne prate lager
|
||||
switch r.FormValue("tip") {
|
||||
case model.TipUsluga:
|
||||
artikal.Tip = model.TipUsluga
|
||||
case model.TipTrosak:
|
||||
artikal.Tip = model.TipTrosak
|
||||
default:
|
||||
artikal.Tip = model.TipProizvod
|
||||
}
|
||||
|
||||
// jedinica mere — normalizacija: mala slova, samo slova/brojevi, max 4 karaktera
|
||||
artikal.JedinicaMere = normalizujJM(r.FormValue("jedinica_mere"))
|
||||
|
||||
if k := r.FormValue("kolicina"); k != "" {
|
||||
v, err := strconv.Atoi(k)
|
||||
if err != nil || v < 0 {
|
||||
@@ -241,6 +335,20 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
artikal.KolicinMin = v
|
||||
}
|
||||
|
||||
// usluge i troškovi nemaju stanje na lageru
|
||||
if !artikal.PratiLager() {
|
||||
artikal.Kolicina = 0
|
||||
artikal.KolicinMin = 0
|
||||
}
|
||||
|
||||
if c := r.FormValue("nabavna_cena"); c != "" {
|
||||
v, err := strconv.ParseFloat(c, 64)
|
||||
if err != nil || v < 0 {
|
||||
return artikal, "Nabavna cena mora biti pozitivan broj."
|
||||
}
|
||||
artikal.NabavnaCena = v
|
||||
}
|
||||
|
||||
if c := r.FormValue("prodajna_cena"); c != "" {
|
||||
v, err := strconv.ParseFloat(c, 64)
|
||||
if err != nil || v < 0 {
|
||||
@@ -268,7 +376,43 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
return artikal, ""
|
||||
}
|
||||
|
||||
// PredlogSifre vraća predlog auto-šifre za izabranu kategoriju (poziva forma pri promeni kategorije)
|
||||
func (h *Handler) PredlogSifre(w http.ResponseWriter, r *http.Request) {
|
||||
var kategorijaID *int64
|
||||
if v := r.URL.Query().Get("kategorija"); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
kategorijaID = &id
|
||||
}
|
||||
}
|
||||
sifra, err := h.Artikli.SledecaSifra(r.Context(), kategorijaID)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri generisanju šifre", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(sifra))
|
||||
}
|
||||
|
||||
// renderujFormuArtikla renderuje HTML formu za artikal
|
||||
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
|
||||
h.renderujTemplate(w, "magacin_forma", podaci)
|
||||
}
|
||||
|
||||
// normalizujJM čisti jedinicu mere: mala slova, samo slova i brojevi, max 4 karaktera.
|
||||
// Ako je rezultat prazan, vraća "kom" kao podrazumevanu vrednost.
|
||||
func normalizujJM(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
if b.Len() >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "kom"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -46,19 +46,27 @@ type PodaciDetaljiNabavke struct {
|
||||
}
|
||||
|
||||
// artikalUJSON pretvara listu artikala u template.JS vrednost bezbednu za umetanje u <script> tag
|
||||
func artikalUJSON(artikli []model.ArtikalSaKategorijom) template.JS {
|
||||
func artikalUJSON(artikli []model.ArtikalSaKategorijom, vezeDobavljaca map[int64][]int64) template.JS {
|
||||
type stavka struct {
|
||||
ID int64 `json:"id"`
|
||||
Naziv string `json:"naziv"`
|
||||
PdvStopa float64 `json:"pdv_stopa"`
|
||||
NabavnaCena float64 `json:"nabavna_cena"` // poslednja nabavna cena — predlog za Cena/kom
|
||||
Marza *float64 `json:"marza"` // marža artikla; null = nije postavljeno
|
||||
KategorijaMarza *float64 `json:"kategorija_marza"` // marža kategorije; fallback ako artikal nema
|
||||
Dobavljaci []int64 `json:"dobavljaci"` // ID-jevi dobavljača koji isporučuju artikal
|
||||
}
|
||||
lista := make([]stavka, 0, len(artikli))
|
||||
for _, a := range artikli {
|
||||
dob := vezeDobavljaca[a.ID]
|
||||
if dob == nil {
|
||||
dob = []int64{}
|
||||
}
|
||||
lista = append(lista, stavka{
|
||||
ID: a.ID, Naziv: a.Naziv, PdvStopa: a.PdvStopa,
|
||||
Marza: a.Marza, KategorijaMarza: a.KategorijaMarza,
|
||||
NabavnaCena: a.NabavnaCena,
|
||||
Marza: a.Marza, KategorijaMarza: a.KategorijaMarza,
|
||||
Dobavljaci: dob,
|
||||
})
|
||||
}
|
||||
b, _ := json.Marshal(lista)
|
||||
@@ -118,13 +126,15 @@ func (h *Handler) NovaNabavka(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
veze, _ := h.Artikli.SveDobavljaceArtikala(r.Context())
|
||||
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "nabavke"
|
||||
ps.NaslovStranice = "Nova nabavka"
|
||||
h.renderujFormuNabavke(w, PodaciFormeNabavke{
|
||||
PodaciStranice: ps,
|
||||
Artikli: artikli,
|
||||
ArtikliJSON: artikalUJSON(artikli),
|
||||
ArtikliJSON: artikalUJSON(artikli, veze),
|
||||
Dobavljaci: dobavljaci,
|
||||
Kategorije: kategorije,
|
||||
Marza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
|
||||
@@ -149,13 +159,14 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
|
||||
artikli, _ := h.Artikli.Lista(r.Context(), db.ArtikalFilter{})
|
||||
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||
veze, _ := h.Artikli.SveDobavljaceArtikala(r.Context())
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "nabavke"
|
||||
ps.NaslovStranice = "Nova nabavka"
|
||||
h.renderujFormuNabavke(w, PodaciFormeNabavke{
|
||||
PodaciStranice: ps,
|
||||
Artikli: artikli,
|
||||
ArtikliJSON: artikalUJSON(artikli),
|
||||
ArtikliJSON: artikalUJSON(artikli, veze),
|
||||
Dobavljaci: dobavljaci,
|
||||
Kategorije: kategorije,
|
||||
Marza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
|
||||
@@ -241,6 +252,15 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// auto-veza artikal–dobavljač: svaki nabavljeni artikal se veže za dobavljača nabavke
|
||||
if nabavka.DobavljacID != nil {
|
||||
for _, s := range stavke {
|
||||
if e := h.Artikli.PoveziDobavljaca(r.Context(), s.ArtikalID, *nabavka.DobavljacID); e != nil {
|
||||
slog.Error("auto-veza dobavljača nije upisana", "artikal_id", s.ArtikalID, "error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
@@ -34,6 +35,18 @@ func (h *Handler) Nivelacije(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
odStr := r.URL.Query().Get("od")
|
||||
doStr := r.URL.Query().Get("do")
|
||||
// podrazumevano: tekući mesec (od prvog do poslednjeg dana)
|
||||
if odStr == "" || doStr == "" {
|
||||
sada := time.Now()
|
||||
prvi := time.Date(sada.Year(), sada.Month(), 1, 0, 0, 0, 0, sada.Location())
|
||||
poslednji := prvi.AddDate(0, 1, -1)
|
||||
if odStr == "" {
|
||||
odStr = prvi.Format("2006-01-02")
|
||||
}
|
||||
if doStr == "" {
|
||||
doStr = poslednji.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
zapisi, err := h.NivelacijaRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr))
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju nivelacija", http.StatusInternalServerError)
|
||||
@@ -80,10 +93,11 @@ func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case errors.Is(err, sqlite.ErrArtikalNePostoji):
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.")
|
||||
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
|
||||
case err != nil:
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri promeni cene.")
|
||||
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
|
||||
default:
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajna cena je izmenjena.")
|
||||
http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,18 @@ func (h *Handler) PdvKir(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
odStr := r.URL.Query().Get("od")
|
||||
doStr := r.URL.Query().Get("do")
|
||||
// podrazumevano: tekući mesec (od prvog do poslednjeg dana)
|
||||
if odStr == "" || doStr == "" {
|
||||
sada := time.Now()
|
||||
prvi := time.Date(sada.Year(), sada.Month(), 1, 0, 0, 0, 0, sada.Location())
|
||||
poslednji := prvi.AddDate(0, 1, -1)
|
||||
if odStr == "" {
|
||||
odStr = prvi.Format("2006-01-02")
|
||||
}
|
||||
if doStr == "" {
|
||||
doStr = poslednji.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
zapisi, err := h.PdvKirRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr))
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju knjige izdatih računa", http.StatusInternalServerError)
|
||||
@@ -157,8 +169,7 @@ func (h *Handler) SacuvajPdvKir(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Izlazni račun je dodat u KIR.")
|
||||
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/pdv/kir?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ObrisiPdvKir briše zapis iz KIR
|
||||
@@ -175,6 +186,5 @@ func (h *Handler) ObrisiPdvKir(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KIR.")
|
||||
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/pdv/kir?obrisan=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,18 @@ func (h *Handler) PdvKpr(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
odStr := r.URL.Query().Get("od")
|
||||
doStr := r.URL.Query().Get("do")
|
||||
// podrazumevano: tekući mesec (od prvog do poslednjeg dana)
|
||||
if odStr == "" || doStr == "" {
|
||||
sada := time.Now()
|
||||
prvi := time.Date(sada.Year(), sada.Month(), 1, 0, 0, 0, 0, sada.Location())
|
||||
poslednji := prvi.AddDate(0, 1, -1)
|
||||
if odStr == "" {
|
||||
odStr = prvi.Format("2006-01-02")
|
||||
}
|
||||
if doStr == "" {
|
||||
doStr = poslednji.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
zapisi, err := h.PdvKprRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr))
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju knjige primljenih računa", http.StatusInternalServerError)
|
||||
@@ -142,8 +154,7 @@ func (h *Handler) SacuvajPdvKpr(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Ulazni račun je dodat u KPR.")
|
||||
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/pdv/kpr?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ObrisiPdvKpr briše zapis iz KPR
|
||||
@@ -160,6 +171,5 @@ func (h *Handler) ObrisiPdvKpr(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KPR.")
|
||||
http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/pdv/kpr?obrisan=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -99,8 +99,7 @@ func (h *Handler) DodajPdvStopu(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Greška pri čuvanju PDV stope", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je dodata.")
|
||||
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// IzmeniPdvStopu prima POST i menja postojeću stopu
|
||||
@@ -128,8 +127,7 @@ func (h *Handler) IzmeniPdvStopu(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Greška pri izmeni PDV stope", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je izmenjena.")
|
||||
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// PromeniAktivnostPdvStope arhivira ili vraća stopu u upotrebu (toggle, bez brisanja)
|
||||
@@ -151,10 +149,5 @@ func (h *Handler) PromeniAktivnostPdvStope(w http.ResponseWriter, r *http.Reques
|
||||
http.Error(w, "Greška pri promeni statusa PDV stope", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
poruka := "PDV stopa je arhivirana."
|
||||
if !postojeca.Aktivna {
|
||||
poruka = "PDV stopa je vraćena u upotrebu."
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
|
||||
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/kalkulacija-pdv?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ import (
|
||||
// PodaciPodesavanja su podaci za stranicu podešavanja
|
||||
type PodaciPodesavanja struct {
|
||||
model.PodaciStranice
|
||||
NazivFirme string
|
||||
Podnazlov string
|
||||
Adresa string
|
||||
Telefon string
|
||||
PIB string
|
||||
LogoPutanja string
|
||||
TopbarLogoSlika bool
|
||||
NazivFirme string
|
||||
Podnazlov string
|
||||
Adresa string
|
||||
Telefon string
|
||||
PIB string
|
||||
LogoPutanja string
|
||||
TopbarLogoSlika bool
|
||||
// profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale
|
||||
FirmaPravniOblik string
|
||||
FirmaPdvObveznik string
|
||||
@@ -43,6 +43,8 @@ type PodaciPodesavanja struct {
|
||||
BackupIntervalSati string
|
||||
BackupBrojKopija string
|
||||
KalkulacijaMarza string
|
||||
ServisGarancijaMeseci string
|
||||
PredracunRokDana string
|
||||
LoginPozadina string
|
||||
LoginPozadinaOpacity string
|
||||
LoginPozadinaBlurPozadine string
|
||||
@@ -265,8 +267,11 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// whitelist dozvoljenih redirekcija — vrednost uvek dolazi iz mape, nikad od korisnika
|
||||
dozvoljeniSledeci := map[string]string{
|
||||
"/admin/podesavanja/opste": "/admin/podesavanja/opste",
|
||||
"/podesavanja": "/podesavanja",
|
||||
"/admin/podesavanja/opste": "/admin/podesavanja/opste",
|
||||
"/admin/podesavanja/sistem": "/admin/podesavanja/sistem",
|
||||
"/admin/podesavanja/servis": "/admin/podesavanja/servis",
|
||||
"/admin/podesavanja/kalkulacija-pdv": "/admin/podesavanja/kalkulacija-pdv",
|
||||
"/podesavanja": "/podesavanja",
|
||||
}
|
||||
sledeci := "/podesavanja"
|
||||
if v, ok := dozvoljeniSledeci[r.FormValue("_next")]; ok {
|
||||
@@ -314,6 +319,34 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// podrazumevani rok garancije za servis (meseci, 0–120)
|
||||
if v := strings.TrimSpace(r.FormValue("servis_garancija_meseci")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 || n > 120 {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Rok garancije mora biti broj između 0 i 120 meseci.")
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_garancija_meseci", strconv.Itoa(n)); err != nil {
|
||||
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// rok važenja predračuna u danima (1–90)
|
||||
if v := strings.TrimSpace(r.FormValue("predracun_rok_dana")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 || n > 90 {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Rok važenja predračuna mora biti broj između 1 i 90 dana.")
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "predracun_rok_dana", strconv.Itoa(n)); err != nil {
|
||||
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, sledeci+"?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -344,20 +377,20 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
||||
// ograničavamo telo zahteva na 2MB + malo za zaglavlja forme
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096)
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
fajl, zaglavlje, err := r.FormFile("logo")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Nije+odabran+fajl", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Nije+odabran+fajl", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
defer fajl.Close()
|
||||
|
||||
// eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera)
|
||||
if zaglavlje.Size > 2<<20 {
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -371,7 +404,7 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ocekivaniMime, ok := dozvoljenoExt[ext]
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Dozvoljeni+formati+su+PNG%2C+JPG+i+SVG", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Dozvoljeni+formati+su+PNG%2C+JPG+i+SVG", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -381,12 +414,12 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
||||
n, _ := fajl.Read(buf)
|
||||
stvarniMime := http.DetectContentType(buf[:n])
|
||||
if !strings.HasPrefix(stvarniMime, ocekivaniMime) {
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Sadržaj+fajla+ne+odgovara+odabranoj+ekstenziji", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Sadržaj+fajla+ne+odgovara+odabranoj+ekstenziji", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// vraćamo kursor na početak
|
||||
if _, err := fajl.Seek(0, io.SeekStart); err != nil {
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+obradi+fajla", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+obradi+fajla", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -401,14 +434,14 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
||||
dst, err := os.Create(odrediste)
|
||||
if err != nil {
|
||||
slog.Error("upload loga: ne mogu kreirati fajl", "error", err)
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, fajl); err != nil {
|
||||
slog.Error("upload loga: greška pri kopiranju", "error", err)
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -416,11 +449,11 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
||||
putanja := fmt.Sprintf("/static/uploads/logo%s?v=%d", ext, time.Now().Unix())
|
||||
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
|
||||
slog.Error("upload loga: greška pri čuvanju putanje", "error", err)
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// UkloniLogo briše logo fajl i čisti putanju iz podešavanja
|
||||
@@ -438,6 +471,20 @@ func (h *Handler) UkloniLogo(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// PodesavanjaServis renderuje stranicu sa podešavanjima servisnog modula
|
||||
func (h *Handler) PodesavanjaServis(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok {
|
||||
return
|
||||
}
|
||||
podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Servis")
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
podaci.Stranica = "podesavanja-servis"
|
||||
h.renderujTemplate(w, "podesavanja_servis", podaci)
|
||||
}
|
||||
|
||||
// generisiImeUploada vraća slučajno hex ime (16 bajtova) sa datom ekstenzijom
|
||||
func generisiImeUploada(ext string) (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
@@ -507,7 +554,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) {
|
||||
staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if stara := staraPodesavanja["login_pozadina"]; stara != "" {
|
||||
// putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla
|
||||
deoBezverzije := strings.Split(stara, "?")[0]
|
||||
deoBezverzije, _, _ := strings.Cut(stara, "?")
|
||||
staroIme := filepath.Base(deoBezverzije)
|
||||
os.Remove(filepath.Join("web/static/uploads", staroIme))
|
||||
}
|
||||
@@ -558,7 +605,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err == nil {
|
||||
if stara := podesavanja["login_pozadina"]; stara != "" {
|
||||
deoBezverzije := strings.Split(stara, "?")[0]
|
||||
deoBezverzije, _, _ := strings.Cut(stara, "?")
|
||||
staroIme := filepath.Base(deoBezverzije)
|
||||
os.Remove(filepath.Join("web/static/uploads", staroIme))
|
||||
}
|
||||
@@ -571,8 +618,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.")
|
||||
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// SacuvajLoginPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine login stranice
|
||||
@@ -630,8 +676,7 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Izgled pozadine prijave je sačuvan.")
|
||||
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// napuniPodaciPodesavanja učitava sva podešavanja i kreira strukturu za template
|
||||
@@ -669,6 +714,8 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
|
||||
BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"),
|
||||
BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
|
||||
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
|
||||
ServisGarancijaMeseci: vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"),
|
||||
PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
|
||||
"LoginPozadinaBlurPozadine": loginBlurPozadine,
|
||||
"LoginPozadinaBlurKartice": loginBlurKartice,
|
||||
"LoginPozadinaZatamnjenjeKartice": loginZatamnjenjeKartice,
|
||||
"Verzija": h.Verzija,
|
||||
"JelDemo": h.JelDemo,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,6 +98,8 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme)
|
||||
if err != nil {
|
||||
// izjednačavamo vreme odgovora sa slučajem postojećeg korisnika (anti-enumeracija)
|
||||
auth.IzjednaciVremeProvere(lozinka)
|
||||
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false)
|
||||
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "korisnik_ne_postoji", false)
|
||||
auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_user")
|
||||
@@ -267,7 +271,7 @@ func (h *Handler) Odjava(w http.ResponseWriter, r *http.Request) {
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
Secure: os.Getenv("NTECH_ENV") == "production",
|
||||
Secure: os.Getenv("NTECH_ENV") == "production" || os.Getenv("NTECH_ENV") == "demo",
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
@@ -316,7 +320,7 @@ func napraviKolacic(token string, istice time.Time) *http.Cookie {
|
||||
Path: "/",
|
||||
Expires: istice,
|
||||
HttpOnly: true,
|
||||
Secure: os.Getenv("NTECH_ENV") == "production",
|
||||
Secure: os.Getenv("NTECH_ENV") == "production" || os.Getenv("NTECH_ENV") == "demo",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,13 +325,14 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok {
|
||||
return
|
||||
}
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil {
|
||||
if err := h.ProdajaRepo.Obrisi(r.Context(), id, &k.ID); err != nil {
|
||||
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -432,8 +433,7 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
|
||||
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
|
||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
|
||||
|
||||
+130
-18
@@ -15,6 +15,22 @@ import (
|
||||
"ntech/internal/middleware"
|
||||
)
|
||||
|
||||
// sacuvanoReferer vraća URL za redirect posle uspešnog čuvanja:
|
||||
// koristi Referer zaglavlje (samo putanja, nikad spoljni sajt) i dodaje ?sacuvano=1.
|
||||
// AJAX handler u browseru detektuje sacuvano=1 i prikazuje toast bez reloada.
|
||||
func sacuvanoReferer(r *http.Request, rezervna string) string {
|
||||
if ref := r.Referer(); ref != "" {
|
||||
if u, err := url.Parse(ref); err == nil {
|
||||
putanja := u.RequestURI()
|
||||
if strings.Contains(putanja, "?") {
|
||||
return putanja + "&sacuvano=1"
|
||||
}
|
||||
return putanja + "?sacuvano=1"
|
||||
}
|
||||
}
|
||||
return rezervna + "?sacuvano=1"
|
||||
}
|
||||
|
||||
// ProfilTema prikazuje stranicu lične teme i pozadine
|
||||
func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
@@ -48,6 +64,8 @@ func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) {
|
||||
LokalnaPozadinaBlur: svezi.LokalnaPozadinaBlur,
|
||||
LokalnaPozadinaBlurPozadine: svezi.LokalnaPozadinaBlurPozadine,
|
||||
LokalnaPozadinaGlassOpacity: svezi.LokalnaPozadinaGlassOpacity,
|
||||
LokalnaAnimacija: svezi.LokalnaAnimacija,
|
||||
LokalniHover: svezi.LokalniHover,
|
||||
}
|
||||
if podaci.LokalnaPozadinaOpacity == "" {
|
||||
podaci.LokalnaPozadinaOpacity = "50"
|
||||
@@ -175,8 +193,7 @@ func (h *Handler) ProfilOtpremiPozadinu(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.")
|
||||
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika
|
||||
@@ -200,8 +217,7 @@ func (h *Handler) ProfilUkloniPozadinu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.")
|
||||
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine
|
||||
@@ -248,8 +264,7 @@ func (h *Handler) ProfilSacuvajPozadinuStilove(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Podešavanja su sačuvana.")
|
||||
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// SacuvajLokalnuTemu čuva korisnikovu lokalnu temu
|
||||
@@ -282,15 +297,114 @@ func (h *Handler) SacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Tema je sačuvana.")
|
||||
// koristimo samo putanju iz Referer zaglavlja — nikad ceo URL jer može biti spoljni sajt
|
||||
if ref := r.Referer(); ref != "" {
|
||||
if u, err := url.Parse(ref); err == nil {
|
||||
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// SacuvajLokalnuAnimaciju čuva korisnikovu preferencu animacije tabela
|
||||
func (h *Handler) SacuvajLokalnuAnimaciju(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
if k == nil {
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") {
|
||||
http.Error(w, "Pristup odbijen", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
animacija := r.FormValue("lokalna_animacija")
|
||||
dozvoljene := map[string]bool{"": true, "bez": true, "fadeInUp": true, "fadeIn": true, "blurIn": true, "slideLeft": true}
|
||||
if !dozvoljene[animacija] {
|
||||
animacija = ""
|
||||
}
|
||||
|
||||
brzina := r.FormValue("lokalna_brzina_animacije")
|
||||
dozvoljenieBrzine := map[string]bool{"": true, "0.1": true, "0.2": true, "0.3": true, "0.4": true, "0.5": true, "0.6": true, "0.7": true, "0.8": true}
|
||||
if !dozvoljenieBrzine[brzina] {
|
||||
brzina = ""
|
||||
}
|
||||
|
||||
if err := h.KorisniciRepo.SacuvajLokalnuAnimaciju(r.Context(), k.ID, animacija); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// SacuvajLokalniHover čuva korisnikovu preferencu hover efekta na karticama
|
||||
func (h *Handler) SacuvajLokalniHover(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
if k == nil {
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") {
|
||||
http.Error(w, "Pristup odbijen", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
hover := r.FormValue("lokalni_hover")
|
||||
dozvoljeni := map[string]bool{"": true, "bez": true, "podizanje": true, "svetlost": true, "zoom": true, "boja": true}
|
||||
if !dozvoljeni[hover] {
|
||||
hover = ""
|
||||
}
|
||||
|
||||
if err := h.KorisniciRepo.SacuvajLokalniHover(r.Context(), k.ID, hover); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// SacuvajLokalnuBrzinuAnimacije čuva korisnikovu preferencu brzine animacije tabela
|
||||
func (h *Handler) SacuvajLokalnuBrzinuAnimacije(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
if k == nil {
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") {
|
||||
http.Error(w, "Pristup odbijen", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
brzina := r.FormValue("lokalna_brzina_animacije")
|
||||
dozvoljene := map[string]bool{"": true, "0.2": true, "0.4": true, "0.6": true, "0.8": true, "1.0": true, "1.2": true, "1.5": true}
|
||||
if !dozvoljene[brzina] {
|
||||
brzina = ""
|
||||
}
|
||||
|
||||
if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ProfilOtpremiAvatar prima upload lične avatar slike korisnika
|
||||
@@ -384,8 +498,7 @@ func (h *Handler) ProfilOtpremiAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je sačuvan.")
|
||||
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ProfilUkloniAvatar briše ličnu avatar sliku korisnika
|
||||
@@ -409,6 +522,5 @@ func (h *Handler) ProfilUkloniAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je uklonjen.")
|
||||
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
+483
-10
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"ntech/internal/model"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// PodaciServisa su podaci za stranicu sa listom servisnih naloga
|
||||
@@ -46,6 +48,10 @@ type PodaciDetaljiNaloga struct {
|
||||
ServisniDelovi []model.ServisniDeoSaArtiklom
|
||||
Artikli []model.ArtikalSaKategorijom
|
||||
Sacuvano bool
|
||||
UkupnoDelovi float64
|
||||
UkupnoSve float64
|
||||
PreostaloSve float64
|
||||
SviStatusi []string
|
||||
}
|
||||
|
||||
// Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa
|
||||
@@ -110,9 +116,15 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) {
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "servis"
|
||||
ps.NaslovStranice = "Novi nalog"
|
||||
noviNalog := model.ServisniNalog{
|
||||
BrojNaloga: brojNaloga,
|
||||
Status: model.StatusPrimljeno,
|
||||
DatumPrijema: time.Now(),
|
||||
}
|
||||
noviNalog.GarancijaDo = defaultGarancija(noviNalog.DatumPrijema, podesavanja)
|
||||
h.renderujFormuNaloga(w, PodaciFormeNaloga{
|
||||
PodaciStranice: ps,
|
||||
Nalog: model.ServisniNalog{BrojNaloga: brojNaloga, Status: model.StatusPrimljeno},
|
||||
Nalog: noviNalog,
|
||||
Klijenti: klijenti,
|
||||
Tehnicari: tehnicari,
|
||||
SviStatusi: model.SviStatusi,
|
||||
@@ -206,6 +218,9 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if nalog.GarancijaDo == nil {
|
||||
nalog.GarancijaDo = defaultGarancija(nalog.DatumPrijema, podesavanja)
|
||||
}
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "servis"
|
||||
ps.NaslovStranice = "Izmeni nalog"
|
||||
@@ -346,6 +361,23 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Error("greška pri učitavanju artikala", "error", err)
|
||||
}
|
||||
|
||||
var ukupnoDelovi float64
|
||||
for _, d := range delovi {
|
||||
ukupnoDelovi += d.Ukupno()
|
||||
}
|
||||
var ukupnoSve, preostaloSve float64
|
||||
if nalog.CenaKonacna != nil {
|
||||
ukupnoSve = *nalog.CenaKonacna + ukupnoDelovi
|
||||
avans := 0.0
|
||||
if nalog.Avans != nil {
|
||||
avans = *nalog.Avans
|
||||
}
|
||||
preostaloSve = ukupnoSve - avans
|
||||
if preostaloSve < 0 {
|
||||
preostaloSve = 0
|
||||
}
|
||||
}
|
||||
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "servis"
|
||||
ps.NaslovStranice = "Detalji naloga"
|
||||
@@ -357,6 +389,10 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
|
||||
ServisniDelovi: delovi,
|
||||
Artikli: artikli,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
UkupnoDelovi: ukupnoDelovi,
|
||||
UkupnoSve: ukupnoSve,
|
||||
PreostaloSve: preostaloSve,
|
||||
SviStatusi: model.SviStatusi,
|
||||
}
|
||||
|
||||
h.renderujTemplate(w, "servis_detalji", podaci)
|
||||
@@ -408,8 +444,7 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je dodat.")
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga
|
||||
@@ -434,10 +469,7 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
|
||||
slog.Error("greška pri brisanju dela", "error", err)
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.")
|
||||
} else {
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je uklonjen.")
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -460,6 +492,17 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
|
||||
OpisKvara: opisKvara,
|
||||
Status: r.FormValue("status"),
|
||||
Napomena: strings.TrimSpace(r.FormValue("napomena")),
|
||||
Ostecenja: strings.TrimSpace(r.FormValue("ostecenja")),
|
||||
PinUredjaja: strings.TrimSpace(r.FormValue("pin_uredjaja")),
|
||||
Pribor: strings.TrimSpace(r.FormValue("pribor")),
|
||||
DatumPrijema: time.Now(),
|
||||
}
|
||||
|
||||
// datum prijema — korisnik može da unese drugi datum (npr. retroaktivno)
|
||||
if dp := strings.TrimSpace(r.FormValue("datum_prijema")); dp != "" {
|
||||
if t, err := time.Parse("2006-01-02", dp); err == nil {
|
||||
nalog.DatumPrijema = t
|
||||
}
|
||||
}
|
||||
|
||||
if nalog.Status == "" {
|
||||
@@ -493,16 +536,29 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
|
||||
}
|
||||
}
|
||||
|
||||
// opcioni datum garancije
|
||||
if gd := strings.TrimSpace(r.FormValue("garancija_do")); gd != "" {
|
||||
if t, err := time.Parse("2006-01-02", gd); err == nil {
|
||||
nalog.GarancijaDo = &t
|
||||
// opcioni datum garancije — preskačemo ako je korisnik označio "bez garancije"
|
||||
if r.FormValue("bez_garancije") != "1" {
|
||||
if gd := strings.TrimSpace(r.FormValue("garancija_do")); gd != "" {
|
||||
if t, err := time.Parse("2006-01-02", gd); err == nil {
|
||||
nalog.GarancijaDo = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nalog, ""
|
||||
}
|
||||
|
||||
// defaultGarancija vraća datum garancije na osnovu datuma prijema i podešavanja;
|
||||
// vraća nil ako je rok 0 ili podešavanje nedostaje
|
||||
func defaultGarancija(datumPrijema time.Time, podesavanja map[string]string) *time.Time {
|
||||
meseci, err := strconv.Atoi(vrednostIliDefault(podesavanja, "servis_garancija_meseci", "2"))
|
||||
if err != nil || meseci <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := datumPrijema.AddDate(0, meseci, 0)
|
||||
return &t
|
||||
}
|
||||
|
||||
// parseOpcionuCenu pretvara string u *float64 — prazno polje ili neispravna vrednost vraća nil
|
||||
func parseOpcionuCenu(s string) *float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
@@ -520,3 +576,420 @@ func parseOpcionuCenu(s string) *float64 {
|
||||
func (h *Handler) renderujFormuNaloga(w http.ResponseWriter, podaci PodaciFormeNaloga) {
|
||||
h.renderujTemplate(w, "servis_forma", podaci)
|
||||
}
|
||||
|
||||
// PodaciStampeServisa su podaci za print-friendly prikaz servisnog naloga
|
||||
type PodaciStampeServisa struct {
|
||||
Nalog model.ServisniNalog
|
||||
ServisniDelovi []model.ServisniDeoSaArtiklom
|
||||
UkupnoDelovi float64
|
||||
KlijentNaziv string
|
||||
TehnicarNaziv string
|
||||
NazivFirme string
|
||||
Podnazlov string
|
||||
Adresa string
|
||||
Telefon string
|
||||
PIB string
|
||||
QRKod string // base64 PNG QR koda sa URL-om naloga
|
||||
}
|
||||
|
||||
// StampaServisa renderuje print-friendly stranicu za servisni nalog
|
||||
func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
nalog, err := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Nalog nije pronađen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
klijentNaziv := ""
|
||||
if nalog.KlijentID != nil {
|
||||
klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
|
||||
if err == nil {
|
||||
if klijent.NazivFirme != "" {
|
||||
klijentNaziv = klijent.NazivFirme
|
||||
} else {
|
||||
klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tehnicarNaziv := ""
|
||||
if nalog.TehnicarID != nil {
|
||||
tehnicar, err := h.KorisniciRepo.DohvatiPoID(r.Context(), *nalog.TehnicarID)
|
||||
if err == nil {
|
||||
tehnicarNaziv = tehnicar.KorisnickoIme
|
||||
}
|
||||
}
|
||||
|
||||
var ukupnoDelovi float64
|
||||
for _, d := range delovi {
|
||||
ukupnoDelovi += d.Ukupno()
|
||||
}
|
||||
|
||||
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
|
||||
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||
var qrKod string
|
||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||
qrKod = base64.StdEncoding.EncodeToString(png)
|
||||
}
|
||||
|
||||
h.renderujStandalone(w, "servis_stampa", PodaciStampeServisa{
|
||||
Nalog: *nalog,
|
||||
ServisniDelovi: delovi,
|
||||
UkupnoDelovi: ukupnoDelovi,
|
||||
KlijentNaziv: klijentNaziv,
|
||||
TehnicarNaziv: tehnicarNaziv,
|
||||
NazivFirme: podesavanja["naziv_firme"],
|
||||
Podnazlov: podesavanja["podnazlov"],
|
||||
Adresa: podesavanja["adresa"],
|
||||
Telefon: podesavanja["telefon"],
|
||||
PIB: podesavanja["pib"],
|
||||
QRKod: qrKod,
|
||||
})
|
||||
}
|
||||
|
||||
// PodaciOtpremnice su podaci za otpremnicu pri preuzimanju uređaja
|
||||
type PodaciOtpremnice struct {
|
||||
Nalog model.ServisniNalog
|
||||
ServisniDelovi []model.ServisniDeoSaArtiklom
|
||||
UkupnoDelovi float64
|
||||
PreostaloSve float64
|
||||
ImaAvans bool
|
||||
QRKod string
|
||||
Klijent *model.Klijent
|
||||
KlijentNaziv string
|
||||
TehnicarNaziv string
|
||||
NazivFirme string
|
||||
Podnazlov string
|
||||
Adresa string
|
||||
Telefon string
|
||||
PIB string
|
||||
}
|
||||
|
||||
// StampaOtpremnice renderuje otpremnicu pri preuzimanju uređaja od strane klijenta
|
||||
func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
nalog, err := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Nalog nije pronađen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var klijent *model.Klijent
|
||||
klijentNaziv := ""
|
||||
if nalog.KlijentID != nil {
|
||||
k, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
|
||||
if err == nil {
|
||||
klijent = k
|
||||
if k.NazivFirme != "" {
|
||||
klijentNaziv = k.NazivFirme
|
||||
} else {
|
||||
klijentNaziv = strings.TrimSpace(k.Ime + " " + k.Prezime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tehnicarNaziv := ""
|
||||
if nalog.TehnicarID != nil {
|
||||
tehnicar, err := h.KorisniciRepo.DohvatiPoID(r.Context(), *nalog.TehnicarID)
|
||||
if err == nil {
|
||||
tehnicarNaziv = tehnicar.KorisnickoIme
|
||||
}
|
||||
}
|
||||
|
||||
var ukupnoDelovi float64
|
||||
for _, d := range delovi {
|
||||
ukupnoDelovi += d.Ukupno()
|
||||
}
|
||||
var preostaloSve float64
|
||||
var imaAvans bool
|
||||
if nalog.CenaKonacna != nil {
|
||||
ukupnoSve := *nalog.CenaKonacna + ukupnoDelovi
|
||||
avans := 0.0
|
||||
if nalog.Avans != nil && *nalog.Avans > 0 {
|
||||
avans = *nalog.Avans
|
||||
imaAvans = true
|
||||
}
|
||||
preostaloSve = ukupnoSve - avans
|
||||
if preostaloSve < 0 {
|
||||
preostaloSve = 0
|
||||
}
|
||||
}
|
||||
|
||||
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||
var qrKodOtpr string
|
||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||
qrKodOtpr = base64.StdEncoding.EncodeToString(png)
|
||||
}
|
||||
|
||||
h.renderujStandalone(w, "servis_otpremnica", PodaciOtpremnice{
|
||||
Nalog: *nalog,
|
||||
ServisniDelovi: delovi,
|
||||
UkupnoDelovi: ukupnoDelovi,
|
||||
PreostaloSve: preostaloSve,
|
||||
ImaAvans: imaAvans,
|
||||
QRKod: qrKodOtpr,
|
||||
Klijent: klijent,
|
||||
KlijentNaziv: klijentNaziv,
|
||||
TehnicarNaziv: tehnicarNaziv,
|
||||
NazivFirme: podesavanja["naziv_firme"],
|
||||
Podnazlov: podesavanja["podnazlov"],
|
||||
Adresa: podesavanja["adresa"],
|
||||
Telefon: podesavanja["telefon"],
|
||||
PIB: podesavanja["pib"],
|
||||
})
|
||||
}
|
||||
|
||||
// PodaciPredracuna su podaci za predračun/ponudu koja se šalje klijentu pre rada
|
||||
type PodaciPredracuna struct {
|
||||
Nalog model.ServisniNalog
|
||||
ServisniDelovi []model.ServisniDeoSaArtiklom
|
||||
UkupnoDelovi float64
|
||||
|
||||
ImaCenuRada bool // ima li nalog uopšte cenu rada (raspon ili fiksnu)
|
||||
CenaRaspon bool // true → prikaži procenu Od–Do; false → fiksnu cenu
|
||||
CenaRadaOd float64 // donja granica procene
|
||||
CenaRadaDo float64 // gornja granica procene
|
||||
CenaRada float64 // fiksna cena rada
|
||||
|
||||
UkupnoOd float64 // delovi + CenaRadaOd (kad je raspon)
|
||||
UkupnoDo float64 // delovi + CenaRadaDo (kad je raspon)
|
||||
Ukupno float64 // delovi + fiksna cena (ili samo delovi ako nema cene rada)
|
||||
|
||||
DatumIzdavanja time.Time
|
||||
VaziDo time.Time
|
||||
|
||||
QRKod string
|
||||
Klijent *model.Klijent
|
||||
KlijentNaziv string
|
||||
TehnicarNaziv string
|
||||
NazivFirme string
|
||||
Podnazlov string
|
||||
Adresa string
|
||||
Telefon string
|
||||
PIB string
|
||||
}
|
||||
|
||||
// StampaPredracuna renderuje predračun/ponudu koja se šalje klijentu kada se utvrdi kvar
|
||||
func (h *Handler) StampaPredracuna(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
nalog, err := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Nalog nije pronađen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var klijent *model.Klijent
|
||||
klijentNaziv := ""
|
||||
if nalog.KlijentID != nil {
|
||||
k, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
|
||||
if err == nil {
|
||||
klijent = k
|
||||
if k.NazivFirme != "" {
|
||||
klijentNaziv = k.NazivFirme
|
||||
} else {
|
||||
klijentNaziv = strings.TrimSpace(k.Ime + " " + k.Prezime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tehnicarNaziv := ""
|
||||
if nalog.TehnicarID != nil {
|
||||
tehnicar, err := h.KorisniciRepo.DohvatiPoID(r.Context(), *nalog.TehnicarID)
|
||||
if err == nil {
|
||||
tehnicarNaziv = tehnicar.KorisnickoIme
|
||||
}
|
||||
}
|
||||
|
||||
var ukupnoDelovi float64
|
||||
for _, d := range delovi {
|
||||
ukupnoDelovi += d.Ukupno()
|
||||
}
|
||||
|
||||
// cena rada: ako postoji procena raspona (Od i Do) prikaži je, inače padni na fiksnu cenu
|
||||
var imaCenuRada, cenaRaspon bool
|
||||
var cenaRadaOd, cenaRadaDo, cenaRada float64
|
||||
var ukupnoOd, ukupnoDo, ukupno float64
|
||||
switch {
|
||||
case nalog.CenaOd != nil && nalog.CenaDo != nil:
|
||||
imaCenuRada = true
|
||||
cenaRaspon = true
|
||||
cenaRadaOd = *nalog.CenaOd
|
||||
cenaRadaDo = *nalog.CenaDo
|
||||
ukupnoOd = ukupnoDelovi + cenaRadaOd
|
||||
ukupnoDo = ukupnoDelovi + cenaRadaDo
|
||||
case nalog.CenaKonacna != nil:
|
||||
imaCenuRada = true
|
||||
cenaRada = *nalog.CenaKonacna
|
||||
ukupno = ukupnoDelovi + cenaRada
|
||||
default:
|
||||
ukupno = ukupnoDelovi
|
||||
}
|
||||
|
||||
// rok važenja iz podešavanja (default 7 dana)
|
||||
rok := 7
|
||||
if v, err := strconv.Atoi(podesavanja["predracun_rok_dana"]); err == nil && v > 0 {
|
||||
rok = v
|
||||
}
|
||||
datumIzdavanja := time.Now()
|
||||
vaziDo := datumIzdavanja.AddDate(0, 0, rok)
|
||||
|
||||
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
|
||||
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||
var qrKod string
|
||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||
qrKod = base64.StdEncoding.EncodeToString(png)
|
||||
}
|
||||
|
||||
h.renderujStandalone(w, "servis_predracun", PodaciPredracuna{
|
||||
Nalog: *nalog,
|
||||
ServisniDelovi: delovi,
|
||||
UkupnoDelovi: ukupnoDelovi,
|
||||
ImaCenuRada: imaCenuRada,
|
||||
CenaRaspon: cenaRaspon,
|
||||
CenaRadaOd: cenaRadaOd,
|
||||
CenaRadaDo: cenaRadaDo,
|
||||
CenaRada: cenaRada,
|
||||
UkupnoOd: ukupnoOd,
|
||||
UkupnoDo: ukupnoDo,
|
||||
Ukupno: ukupno,
|
||||
DatumIzdavanja: datumIzdavanja,
|
||||
VaziDo: vaziDo,
|
||||
QRKod: qrKod,
|
||||
Klijent: klijent,
|
||||
KlijentNaziv: klijentNaziv,
|
||||
TehnicarNaziv: tehnicarNaziv,
|
||||
NazivFirme: podesavanja["naziv_firme"],
|
||||
Podnazlov: podesavanja["podnazlov"],
|
||||
Adresa: podesavanja["adresa"],
|
||||
Telefon: podesavanja["telefon"],
|
||||
PIB: podesavanja["pib"],
|
||||
})
|
||||
}
|
||||
|
||||
// PromeniStatus obrađuje POST /servis/{id}/status i menja samo status naloga
|
||||
func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
noviStatus := strings.TrimSpace(r.FormValue("status"))
|
||||
dozvoljenStatusi := map[string]bool{}
|
||||
for _, s := range model.SviStatusi {
|
||||
dozvoljenStatusi[s] = true
|
||||
}
|
||||
if !dozvoljenStatusi[noviStatus] {
|
||||
http.Error(w, "Nepoznat status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.ServisRepo.AzurirajStatus(r.Context(), id, noviStatus); err != nil {
|
||||
slog.Error("greška pri promeni statusa naloga", "id", id, "error", err)
|
||||
http.Error(w, "Greška pri promeni statusa", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
|
||||
type PodaciJavnogStatusa struct {
|
||||
Nalog model.ServisniNalog
|
||||
NazivFirme string
|
||||
Telefon string
|
||||
Adresa string
|
||||
SviStatusi []string
|
||||
}
|
||||
|
||||
// ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda
|
||||
func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
if token == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
nalog, err := h.ServisRepo.DohvatiJavniToken(r.Context(), token)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
|
||||
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
|
||||
Nalog: *nalog,
|
||||
NazivFirme: podesavanja["naziv_firme"],
|
||||
Telefon: podesavanja["telefon"],
|
||||
Adresa: podesavanja["adresa"],
|
||||
SviStatusi: model.SviStatusi,
|
||||
})
|
||||
}
|
||||
|
||||
// qrNalogURL konstruiše URL za QR kod vodeći računa o reverse proxy-ju.
|
||||
// Ako aplikacija radi iza nginx/Caddy/Traefik koji prekida TLS, r.TLS je nil,
|
||||
// ali X-Forwarded-Proto header sadrži stvarnu šemu.
|
||||
func qrNalogURL(r *http.Request, token string) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host + "/status/" + token
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func RequireAuth(db *sql.DB, totpKljuc []byte) func(http.Handler) http.Handler {
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
Secure: os.Getenv("NTECH_ENV") == "production",
|
||||
Secure: os.Getenv("NTECH_ENV") == "production" || os.Getenv("NTECH_ENV") == "demo",
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
@@ -157,7 +157,7 @@ func postaviFlashGresku(w http.ResponseWriter, poruka string) {
|
||||
Path: "/",
|
||||
MaxAge: 60,
|
||||
HttpOnly: true,
|
||||
Secure: os.Getenv("NTECH_ENV") == "production",
|
||||
Secure: os.Getenv("NTECH_ENV") == "production" || os.Getenv("NTECH_ENV") == "demo",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,17 @@ import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const csrfKolacic = "ntech_csrf"
|
||||
|
||||
// maxTeloUpload je gornja granica veličine tela multipart zahteva (upload fajlova).
|
||||
// Postavlja se u middleware-u pre parsiranja jer čitanje _csrf polja već parsira
|
||||
// telo — bez ovog limita pojedinačni handleri ne mogu da ga efektivno ograniče.
|
||||
// Najveći legitiman upload je 5 MB (avatar, pozadina); ostatak je rezerva za form overhead.
|
||||
const maxTeloUpload = 6 << 20
|
||||
|
||||
type csrfKljucTip struct{}
|
||||
|
||||
var csrfKljuc = csrfKljucTip{}
|
||||
@@ -39,7 +46,7 @@ func CsrfMiddleware(next http.Handler) http.Handler {
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 30,
|
||||
HttpOnly: true,
|
||||
Secure: os.Getenv("NTECH_ENV") == "production",
|
||||
Secure: os.Getenv("NTECH_ENV") == "production" || os.Getenv("NTECH_ENV") == "demo",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
@@ -52,6 +59,11 @@ func CsrfMiddleware(next http.Handler) http.Handler {
|
||||
// validiramo na svim mutabilnim HTTP metodama
|
||||
switch r.Method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
// ograničavamo veličinu tela PRE parsiranja — čitanje _csrf polja parsira
|
||||
// celo telo, pa je ovo jedino mesto gde limit za upload stvarno deluje
|
||||
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTeloUpload)
|
||||
}
|
||||
// čitamo token iz tela forme ili zaglavlja (za AJAX)
|
||||
submitted := r.FormValue("_csrf")
|
||||
if submitted == "" {
|
||||
|
||||
@@ -2,12 +2,23 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Tipovi artikla. Proizvod prati stanje na lageru; usluga i trošak ga ne prate.
|
||||
const (
|
||||
TipProizvod = "proizvod"
|
||||
TipUsluga = "usluga"
|
||||
TipTrosak = "trosak"
|
||||
)
|
||||
|
||||
// Artikal predstavlja jedan artikal u magacinu
|
||||
type Artikal struct {
|
||||
ID int64
|
||||
KategorijaID *int64
|
||||
Sifra string
|
||||
Barkod string
|
||||
Naziv string
|
||||
Opis string
|
||||
Tip string // proizvod | usluga | trosak
|
||||
JedinicaMere string // kom, sat, set, m, l, kg ...
|
||||
Kolicina int
|
||||
KolicinMin int
|
||||
Lokacija string
|
||||
@@ -17,6 +28,13 @@ type Artikal struct {
|
||||
Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno
|
||||
Napomena string
|
||||
DatumUnosa time.Time
|
||||
Arhiviran bool // artikal u prometu koji je sklonjen iz aktivne liste; istorija ostaje
|
||||
}
|
||||
|
||||
// PratiLager vraća true samo za proizvode (usluge i troškovi nemaju stanje na lageru).
|
||||
// Prazan tip se tretira kao proizvod radi kompatibilnosti sa starim zapisima.
|
||||
func (a Artikal) PratiLager() bool {
|
||||
return a.Tip == TipProizvod || a.Tip == ""
|
||||
}
|
||||
|
||||
// CenaBezPdv izračunava prodajnu cenu bez PDV-a
|
||||
@@ -34,6 +52,7 @@ type Kategorija struct {
|
||||
ID int64
|
||||
Naziv string
|
||||
Opis string
|
||||
Kod string // prefiks za šifru artikla (npr. KOMP -> KOMP-0001)
|
||||
Marza *float64 // podrazumevana marža (%) za artikle ove kategorije; NULL = nije postavljeno
|
||||
}
|
||||
|
||||
|
||||
@@ -58,3 +58,27 @@ type TopKlijentRed struct {
|
||||
UkupnoVrednost float64
|
||||
BrojNaloga int
|
||||
}
|
||||
|
||||
// PrometniRed je jedan red prometnog lista magacina
|
||||
type PrometniRed struct {
|
||||
Datum time.Time
|
||||
ArtikalNaziv string
|
||||
ArtikalSifra string
|
||||
TipPromene string
|
||||
PromenaKolicine int
|
||||
StanjePre int
|
||||
StanjePosle int
|
||||
Napomena string
|
||||
}
|
||||
|
||||
// StanjeZalihaRed je jedan red izveštaja o stanju zaliha
|
||||
type StanjeZalihaRed struct {
|
||||
Naziv string
|
||||
Sifra string
|
||||
Kategorija string
|
||||
Kolicina int
|
||||
KolicinMin int
|
||||
NabavnaCena float64
|
||||
ProdajnaCena float64
|
||||
VrednostZalihe float64 // kolicina × nabavna_cena
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ type Korisnik struct {
|
||||
LokalnaPozadinaBlurPozadine string
|
||||
LokalnaPozadinaGlassOpacity string
|
||||
AvatarPutanja string
|
||||
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
|
||||
LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
|
||||
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | "0.6" | "0.8" | "1.2" (sekunde)
|
||||
}
|
||||
|
||||
// Sesija predstavlja aktivnu sesiju prijavljenog korisnika
|
||||
|
||||
@@ -4,15 +4,15 @@ import "time"
|
||||
|
||||
// ProdajniNalog predstavlja zaglavlje jedne prodaje
|
||||
type ProdajniNalog struct {
|
||||
ID int64
|
||||
KlijentID *int64
|
||||
BrojNaloga string
|
||||
Napomena string
|
||||
Ukupno float64
|
||||
NacinPlacanja string
|
||||
Stornirano bool
|
||||
RazlogStorniranja string
|
||||
Datum time.Time
|
||||
ID int64
|
||||
KlijentID *int64
|
||||
BrojNaloga string
|
||||
Napomena string
|
||||
Ukupno float64
|
||||
NacinPlacanja string
|
||||
Stornirano bool
|
||||
RazlogStorniranja string
|
||||
Datum time.Time
|
||||
}
|
||||
|
||||
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
|
||||
@@ -38,4 +38,5 @@ type ProdajniNalogSaDetaljem struct {
|
||||
type StavkaProdajeSaArtiklom struct {
|
||||
StavkaProdaje
|
||||
ArtikalNaziv string
|
||||
JedinicaMere string
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ type ServisniNalog struct {
|
||||
GarancijaDo *time.Time
|
||||
DatumPrijema time.Time
|
||||
DatumZavrsetka *time.Time
|
||||
Ostecenja string
|
||||
PinUredjaja string
|
||||
Pribor string
|
||||
JavniToken string
|
||||
}
|
||||
|
||||
// ServisniDeo predstavlja jedan artikal ugrađen u servisni nalog
|
||||
|
||||
@@ -53,6 +53,9 @@ type PodaciStranice struct {
|
||||
AppPozadinaBlur string // vrednost 0-20 (px backdrop-filter blur na elementima)
|
||||
AppPozadinaBlurPozadine string // vrednost 0-20 (px filter blur na pozadinskoj slici)
|
||||
AppPozadinaGlassOpacity string // vrednost 0-80 (% zatamnjivanje glass elemenata) — samo za ličnu pozadinu
|
||||
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
|
||||
LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
|
||||
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | ... | "1.5" (sekunde)
|
||||
}
|
||||
|
||||
// PodaciDashboarda su podaci specifični za dashboard stranicu
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE servisni_nalozi ADD COLUMN ostecenja TEXT;
|
||||
ALTER TABLE servisni_nalozi ADD COLUMN pin_uredjaja TEXT;
|
||||
ALTER TABLE servisni_nalozi ADD COLUMN pribor TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
INSERT OR IGNORE INTO podesavanja (kljuc, vrednost) VALUES
|
||||
('servis_garancija_meseci', '2');
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE korisnici ADD COLUMN lokalna_animacija TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE korisnici ADD COLUMN lokalni_hover TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE artikli ADD COLUMN sifra TEXT;
|
||||
ALTER TABLE artikli ADD COLUMN barkod TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_artikli_sifra ON artikli(sifra) WHERE sifra IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_artikli_barkod ON artikli(barkod) WHERE barkod IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE korisnici ADD COLUMN lokalna_brzina_animacije TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE servisni_nalozi ADD COLUMN javni_token TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_servisni_nalozi_javni_token ON servisni_nalozi(javni_token);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Arhiviranje artikala: artikal koji je bio u prometu ne može se obrisati (FK RESTRICT),
|
||||
-- pa se umesto brisanja označava kao arhiviran. Arhiviran artikal se ne nudi za nov promet,
|
||||
-- ali ostaje vidljiv u istoriji (prodaja, nabavka, servis) i na svojoj kartici.
|
||||
ALTER TABLE artikli ADD COLUMN arhiviran INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Kôd kategorije služi kao prefiks šifre artikla (npr. "Komponente" -> KOMP -> KOMP-0001).
|
||||
ALTER TABLE kategorije ADD COLUMN kod TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Tip artikla: 'proizvod' (prati lager), 'usluga' i 'trosak' (ne prate lager).
|
||||
ALTER TABLE artikli ADD COLUMN tip TEXT NOT NULL DEFAULT 'proizvod';
|
||||
-- Jedinica mere artikla (kom, sat, set, m, l, kg ...).
|
||||
ALTER TABLE artikli ADD COLUMN jedinica_mere TEXT NOT NULL DEFAULT 'kom';
|
||||
@@ -0,0 +1,3 @@
|
||||
UPDATE kategorije
|
||||
SET kod = UPPER(SUBSTR(REPLACE(naziv, ' ', ''), 1, 4))
|
||||
WHERE (kod IS NULL OR kod = '') AND naziv IS NOT NULL AND naziv != '';
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
GITEA_IMAGE="git.vm-net.in.rs/dasko/ntech"
|
||||
GITHUB_IMAGE="ghcr.io/dalibor31/ntech"
|
||||
VER_FAJL="VERSION"
|
||||
|
||||
clear
|
||||
echo "╔══════════════════════════════════════╗"
|
||||
echo "║ NTech — Build alat ║"
|
||||
echo "╚══════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 1. Verzija
|
||||
VERZIJA_DEFAULT=$(cat "$VER_FAJL" 2>/dev/null || echo "0.0.0")
|
||||
read -p "1) Verzija [${VERZIJA_DEFAULT}]: " VERZIJA
|
||||
VERZIJA="${VERZIJA:-$VERZIJA_DEFAULT}"
|
||||
if [ "$VERZIJA" != "$VERZIJA_DEFAULT" ]; then
|
||||
echo "$VERZIJA" > "$VER_FAJL"
|
||||
echo " → VERSION ažuriran na: $VERZIJA"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 2. Okruženje
|
||||
echo "2) Okruženje:"
|
||||
echo " 1) Production (podrazumevano)"
|
||||
echo " 2) Development"
|
||||
read -p " Izbor [1/2]: " OKR_IZBOR
|
||||
OKR_IZBOR="${OKR_IZBOR:-1}"
|
||||
echo ""
|
||||
|
||||
# 3. Platforma
|
||||
echo "3) Platforma:"
|
||||
echo " 1) Linux (podrazumevano)"
|
||||
echo " 2) Windows"
|
||||
echo " 3) Obe"
|
||||
read -p " Izbor [1/2/3]: " PLATFORMA_IZBOR
|
||||
PLATFORMA_IZBOR="${PLATFORMA_IZBOR:-1}"
|
||||
echo ""
|
||||
|
||||
# 4. UPX
|
||||
read -p "4) Kompresovati UPX-om? [d/N]: " UPX_IZBOR
|
||||
UPX_IZBOR="${UPX_IZBOR:-n}"
|
||||
echo ""
|
||||
|
||||
# 5. Build
|
||||
read -p "5) Pokrenuti build? [D/n]: " BUILD_IZBOR
|
||||
BUILD_IZBOR="${BUILD_IZBOR:-d}"
|
||||
echo ""
|
||||
|
||||
# 6. Docker push
|
||||
read -p "6) Push Docker image (Gitea + GitHub)? [d/N]: " DOCKER_IZBOR
|
||||
DOCKER_IZBOR="${DOCKER_IZBOR:-n}"
|
||||
echo ""
|
||||
|
||||
# ── Izračunaj vrednosti ──────────────────────────
|
||||
if [ "$OKR_IZBOR" = "2" ]; then
|
||||
OKRUZENJE="development"
|
||||
VERZIJA_BUILD="dev-${VERZIJA}"
|
||||
LDFLAGS="-X main.Verzija=dev-${VERZIJA}"
|
||||
TRIMPATH=""
|
||||
else
|
||||
OKRUZENJE="production"
|
||||
VERZIJA_BUILD="${VERZIJA}"
|
||||
LDFLAGS="-X main.Verzija=${VERZIJA} -s -w"
|
||||
TRIMPATH="-trimpath"
|
||||
fi
|
||||
|
||||
case "$PLATFORMA_IZBOR" in
|
||||
2) PLATFORMA_NAZIV="Windows" ;;
|
||||
3) PLATFORMA_NAZIV="Linux + Windows" ;;
|
||||
*) PLATFORMA_NAZIV="Linux" ;;
|
||||
esac
|
||||
|
||||
if [[ "$UPX_IZBOR" =~ ^[dDyY] ]]; then UPX_NAZIV="da"; else UPX_NAZIV="ne"; fi
|
||||
if [[ "$BUILD_IZBOR" =~ ^[dDyY] ]]; then BUILD_NAZIV="da"; else BUILD_NAZIV="ne"; fi
|
||||
if [[ "$DOCKER_IZBOR" =~ ^[dDyY] ]]; then DOCKER_NAZIV="da"; else DOCKER_NAZIV="ne"; fi
|
||||
|
||||
# ── Sažetak ──────────────────────────────────────
|
||||
echo "──────────────────────────────────────────"
|
||||
echo " Verzija : ${VERZIJA_BUILD}"
|
||||
echo " Okruženje : ${OKRUZENJE}"
|
||||
echo " Platforma : ${PLATFORMA_NAZIV}"
|
||||
echo " UPX : ${UPX_NAZIV}"
|
||||
echo " Build : ${BUILD_NAZIV}"
|
||||
echo " Docker : ${DOCKER_NAZIV}"
|
||||
echo "──────────────────────────────────────────"
|
||||
echo ""
|
||||
read -p "Pokrenuti? [D/n]: " POTVRDA
|
||||
POTVRDA="${POTVRDA:-d}"
|
||||
if [[ ! "$POTVRDA" =~ ^[dDyY] ]]; then
|
||||
echo "Otkazano."
|
||||
exit 0
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── UPX: instaliraj ako treba ────────────────────
|
||||
if [[ "$UPX_IZBOR" =~ ^[dDyY] ]] && [[ "$BUILD_IZBOR" =~ ^[dDyY] ]]; then
|
||||
if ! command -v upx &>/dev/null; then
|
||||
echo "→ UPX nije instaliran. Instaliram..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
sudo apt-get install -y upx
|
||||
elif command -v dnf &>/dev/null; then
|
||||
sudo dnf install -y upx
|
||||
elif command -v pacman &>/dev/null; then
|
||||
sudo pacman -S --noconfirm upx
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install upx
|
||||
else
|
||||
echo " UPOZORENJE: Ne mogu da instaliram UPX — nepoznat menadžer paketa. Kompresija preskočena."
|
||||
UPX_IZBOR="n"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Build funkcija ───────────────────────────────
|
||||
build_za() {
|
||||
local GOOS_VAL="$1"
|
||||
local NAZIV="$2"
|
||||
echo "→ Build ${GOOS_VAL}/amd64: ${NAZIV}"
|
||||
CGO_ENABLED=0 GOOS="${GOOS_VAL}" GOARCH=amd64 go build \
|
||||
-ldflags "${LDFLAGS}" \
|
||||
${TRIMPATH} \
|
||||
-o "${NAZIV}" \
|
||||
./cmd/ntech
|
||||
ls -lh "${NAZIV}"
|
||||
|
||||
if [[ "$UPX_IZBOR" =~ ^[dDyY] ]] && command -v upx &>/dev/null; then
|
||||
echo " Kompresovanje sa UPX..."
|
||||
upx --best "${NAZIV}"
|
||||
ls -lh "${NAZIV}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 5. Build ─────────────────────────────────────
|
||||
if [[ "$BUILD_IZBOR" =~ ^[dDyY] ]]; then
|
||||
echo "=== Build ==="
|
||||
case "$PLATFORMA_IZBOR" in
|
||||
2)
|
||||
build_za "windows" "ntech.exe"
|
||||
;;
|
||||
3)
|
||||
build_za "linux" "ntech" &
|
||||
PID_LINUX=$!
|
||||
build_za "windows" "ntech.exe" &
|
||||
PID_WIN=$!
|
||||
wait $PID_LINUX $PID_WIN
|
||||
;;
|
||||
*)
|
||||
build_za "linux" "ntech"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── 6. Docker push ───────────────────────────────
|
||||
if [[ "$DOCKER_IZBOR" =~ ^[dDyY] ]]; then
|
||||
echo "=== Docker ==="
|
||||
|
||||
# Ako Linux binary nije izgrađen u ovom pozivu (build=ne ili platforma=Windows), izgradi ga
|
||||
LINUX_VEC_IZGRADJEN=0
|
||||
if [[ "$BUILD_IZBOR" =~ ^[dDyY] ]] && [ "$PLATFORMA_IZBOR" != "2" ]; then
|
||||
LINUX_VEC_IZGRADJEN=1
|
||||
fi
|
||||
|
||||
if [ "$LINUX_VEC_IZGRADJEN" = "0" ]; then
|
||||
echo "→ Gradim Linux binary za Docker (sa UPX ako je uključen)..."
|
||||
# instaliraj UPX ako treba, a još nije instaliran
|
||||
if [[ "$UPX_IZBOR" =~ ^[dDyY] ]] && ! command -v upx &>/dev/null; then
|
||||
echo "→ UPX nije instaliran. Instaliram..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
sudo apt-get install -y upx
|
||||
elif command -v dnf &>/dev/null; then
|
||||
sudo dnf install -y upx
|
||||
elif command -v pacman &>/dev/null; then
|
||||
sudo pacman -S --noconfirm upx
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install upx
|
||||
else
|
||||
echo " UPOZORENJE: Ne mogu da instaliram UPX — kompresija preskočena."
|
||||
UPX_IZBOR="n"
|
||||
fi
|
||||
fi
|
||||
build_za "linux" "ntech"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "→ Build Docker image..."
|
||||
docker build --build-arg="VERZIJA=${VERZIJA}" \
|
||||
-t "${GITEA_IMAGE}:${VERZIJA}" \
|
||||
-t "${GITEA_IMAGE}:latest" \
|
||||
-t "${GITHUB_IMAGE}:${VERZIJA}" \
|
||||
-t "${GITHUB_IMAGE}:latest" \
|
||||
.
|
||||
|
||||
echo "→ Push na Gitea..."
|
||||
docker push "${GITEA_IMAGE}:${VERZIJA}"
|
||||
docker push "${GITEA_IMAGE}:latest"
|
||||
|
||||
echo "→ Push na GitHub..."
|
||||
docker push "${GITHUB_IMAGE}:${VERZIJA}"
|
||||
docker push "${GITHUB_IMAGE}:latest"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "==> Gotovo! NTech v${VERZIJA_BUILD}"
|
||||
+167
-11
@@ -5,6 +5,16 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root { --anim-trajanje: 0.4s; }
|
||||
[data-brzina-animacije="0.1"] { --anim-trajanje: 0.1s; }
|
||||
[data-brzina-animacije="0.2"] { --anim-trajanje: 0.2s; }
|
||||
[data-brzina-animacije="0.3"] { --anim-trajanje: 0.3s; }
|
||||
[data-brzina-animacije="0.4"] { --anim-trajanje: 0.4s; }
|
||||
[data-brzina-animacije="0.5"] { --anim-trajanje: 0.5s; }
|
||||
[data-brzina-animacije="0.6"] { --anim-trajanje: 0.6s; }
|
||||
[data-brzina-animacije="0.7"] { --anim-trajanje: 0.7s; }
|
||||
[data-brzina-animacije="0.8"] { --anim-trajanje: 0.8s; }
|
||||
|
||||
html {
|
||||
background: var(--pozadina);
|
||||
}
|
||||
@@ -324,6 +334,8 @@ body {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.topbar-logo { height: 34px; width: auto; border-radius: 6px; flex-shrink: 0; }
|
||||
/* sakrivanje preko klase (ne inline display) da mobilni @media koji gasi logo ostane nadređen */
|
||||
.topbar-logo.skriven { display: none; }
|
||||
|
||||
/* sadržaj stranice */
|
||||
.sadrzaj {
|
||||
@@ -339,12 +351,42 @@ body {
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--senka);
|
||||
transition: transform 0.25s cubic-bezier(.4,0,.2,1), box-shadow 0.25s;
|
||||
transition: box-shadow 0.25s, border-color 0.25s, transform 0.25s, background 0.25s;
|
||||
}
|
||||
|
||||
.kartica:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: var(--senka-hover);
|
||||
border-color: var(--ivica-jaka);
|
||||
}
|
||||
|
||||
/* hover varijante po korisničkoj preferenciji.
|
||||
Selektor je [data-hover] (ne body[data-hover]) da bi isti CSS
|
||||
radio i globalno (body) i lokalno na preview wrapperima. */
|
||||
[data-hover="bez"] .kartica:hover {
|
||||
box-shadow: var(--senka);
|
||||
border-color: var(--ivica);
|
||||
transform: none;
|
||||
}
|
||||
[data-hover="podizanje"] .kartica:hover {
|
||||
box-shadow: var(--senka-hover);
|
||||
border-color: var(--ivica-jaka);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
[data-hover="svetlost"] .kartica:hover {
|
||||
box-shadow: 0 0 0 1.5px var(--sb-akcent), 0 0 20px color-mix(in srgb, var(--sb-akcent) 35%, transparent);
|
||||
border-color: var(--sb-akcent);
|
||||
transform: none;
|
||||
}
|
||||
[data-hover="zoom"] .kartica:hover {
|
||||
box-shadow: var(--senka-hover);
|
||||
border-color: var(--ivica-jaka);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
[data-hover="boja"] .kartica:hover {
|
||||
box-shadow: var(--senka-hover);
|
||||
border-color: var(--ivica-jaka);
|
||||
background: color-mix(in srgb, var(--sb-akcent) 6%, var(--kartica));
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* modal za premeštanje artikla — koristi promenljive teme, pa prati svetlu/tamnu;
|
||||
@@ -473,6 +515,9 @@ body {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
/* sitna akciona dugmad ne prelamaju tekst — inače „Promeni cenu" padne u 2 reda
|
||||
pa to dugme postane više od ostalih u istom redu */
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-primarno-malo:hover { opacity: 0.85; }
|
||||
@@ -494,6 +539,10 @@ body {
|
||||
.btn-sekundarno:hover { background: var(--pozadina); color: var(--tekst-glavni); }
|
||||
|
||||
/* crveno dugme za brisanje u tabelama */
|
||||
/* naziv artikla u listi koji vodi na karticu */
|
||||
.link-naziv { text-decoration: none; }
|
||||
.link-naziv:hover { color: var(--sb-akcent); text-decoration: underline; }
|
||||
|
||||
.btn-obrisi-malo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -506,6 +555,7 @@ body {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-obrisi-malo:hover { opacity: 0.8; }
|
||||
@@ -592,7 +642,9 @@ body {
|
||||
color: var(--tekst-sporedni);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 0.5px solid var(--ivica);
|
||||
}
|
||||
|
||||
/* avatar krug korisnika u topbaru */
|
||||
@@ -720,6 +772,9 @@ select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* kad JS radi, .poruka-uspeh se konvertuje u toast — sakrivamo odmah da ne treptne */
|
||||
.js-on .poruka-uspeh { display: none !important; }
|
||||
|
||||
/* poruka o uspehu — konzistentna za sve teme */
|
||||
.poruka-uspeh {
|
||||
background: var(--poruka-uspeh-bg);
|
||||
@@ -758,8 +813,8 @@ select {
|
||||
animation: toastIn 0.3s ease forwards;
|
||||
max-width: 340px;
|
||||
}
|
||||
.toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fca5a5; }
|
||||
.toast-uspeh { background: #f0fdf4; color: #16a34a; border: 0.5px solid #86efac; }
|
||||
.toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; }
|
||||
.toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; }
|
||||
.toast.nestaje { animation: toastOut 0.3s ease forwards; }
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
@@ -976,10 +1031,25 @@ select {
|
||||
|
||||
/* animacije */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes blurIn {
|
||||
from { opacity: 0; filter: blur(8px); }
|
||||
to { opacity: 1; filter: blur(0); }
|
||||
}
|
||||
|
||||
@keyframes slideLeft {
|
||||
from { opacity: 0; transform: translateX(-18px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -1000,14 +1070,59 @@ select {
|
||||
/* backwards (ne both): drži početni frame tokom stagger-delay-a da nema treperenja,
|
||||
ali NE zaključava krajnji transform — pa .kartica:hover lift radi i na .animiraj karticama */
|
||||
.animiraj {
|
||||
animation: fadeInUp 0.2s ease backwards;
|
||||
animation: fadeInUp var(--anim-trajanje) ease backwards;
|
||||
}
|
||||
|
||||
/* kartice ne animiramo po defaultu — animacija je samo na redovima tabela */
|
||||
.kartica.animiraj {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* gasi animaciju redova pri HTMX pretrazi: hx-on:htmx:before-request dodaje ovu klasu
|
||||
na #magacin-rezultati. Pošto pretraga menja samo innerHTML kontejnera, klasa ostaje
|
||||
na njemu i kroz naredne zamene, pa novoubačeni redovi ne animiraju. Pun reload
|
||||
stranice i klik na paginaciju (bez pretrage) ne dodaju klasu, pa animacija ostaje.
|
||||
ID u selektoru je OBAVEZAN: bez njega specifičnost je jednaka pravilima
|
||||
[data-animacija="..."] niže u fajlu, koja bi onda nadjačala ovo i animacija bi
|
||||
se i dalje primenjivala kad korisnik ima izabran stil animacije. */
|
||||
#magacin-rezultati.bez-anim .tabela tbody tr,
|
||||
#magacin-rezultati.bez-anim .animiraj { animation: none; }
|
||||
|
||||
#klijenti-rezultati.bez-anim .tabela tbody tr,
|
||||
#klijenti-rezultati.bez-anim .animiraj { animation: none; }
|
||||
|
||||
#dobavljaci-rezultati.bez-anim .tabela tbody tr,
|
||||
#dobavljaci-rezultati.bez-anim .animiraj { animation: none; }
|
||||
|
||||
/* korisnikova preferencija animacije: body[data-animacija] nadjačava podrazumevano.
|
||||
Kad korisnik odabere stil, animiraju se i redovi tabela i mobilne kartice. */
|
||||
[data-animacija="bez"] .animiraj,
|
||||
[data-animacija="bez"] .tabela tbody tr,
|
||||
[data-animacija="bez"] .kartica.animiraj { animation: none; }
|
||||
|
||||
[data-animacija="fadeInUp"] .animiraj,
|
||||
[data-animacija="fadeInUp"] .tabela tbody tr,
|
||||
[data-animacija="fadeInUp"] .kartica.animiraj { animation: fadeInUp var(--anim-trajanje) ease backwards; }
|
||||
|
||||
[data-animacija="fadeIn"] .animiraj,
|
||||
[data-animacija="fadeIn"] .tabela tbody tr,
|
||||
[data-animacija="fadeIn"] .kartica.animiraj { animation: fadeIn var(--anim-trajanje) ease backwards; }
|
||||
|
||||
[data-animacija="blurIn"] .animiraj,
|
||||
[data-animacija="blurIn"] .tabela tbody tr,
|
||||
[data-animacija="blurIn"] .kartica.animiraj { animation: blurIn var(--anim-trajanje) ease backwards; }
|
||||
|
||||
[data-animacija="slideLeft"] .animiraj,
|
||||
[data-animacija="slideLeft"] .tabela tbody tr,
|
||||
[data-animacija="slideLeft"] .kartica.animiraj { animation: slideLeft var(--anim-trajanje) ease backwards; }
|
||||
|
||||
/* Stepenasta (stagger) animacija redova u svim listama — JEDNO mesto za sve tabele.
|
||||
Pogađa samo redove koji se animiraju (<tr class="animiraj">); ostali ostaju bez
|
||||
delay-a. Mora biti u main.css: HTMX navigacija (hx-select) odbacuje <head>, pa
|
||||
stilovi iz page <style> blokova ne bi važili tokom navigacije.
|
||||
Da promeniš animaciju redova svuda — menjaš samo ovde. */
|
||||
Animacija se primenjuje direktno na <tr> u .tabela, bez potrebe za .animiraj klasom.
|
||||
Mora biti u main.css: HTMX navigacija (hx-select) odbacuje <head>, pa
|
||||
stilovi iz page <style> blokova ne bi važili tokom navigacije. */
|
||||
.tabela tbody tr {
|
||||
animation: fadeInUp var(--anim-trajanje) ease backwards;
|
||||
}
|
||||
.tabela tbody tr:nth-child(1) { animation-delay: 0.04s; }
|
||||
.tabela tbody tr:nth-child(2) { animation-delay: 0.08s; }
|
||||
.tabela tbody tr:nth-child(3) { animation-delay: 0.12s; }
|
||||
@@ -1018,6 +1133,26 @@ select {
|
||||
.tabela tbody tr:nth-child(8) { animation-delay: 0.32s; }
|
||||
.tabela tbody tr:nth-child(9) { animation-delay: 0.36s; }
|
||||
.tabela tbody tr:nth-child(10) { animation-delay: 0.40s; }
|
||||
.tabela tbody tr:nth-child(11) { animation-delay: 0.44s; }
|
||||
.tabela tbody tr:nth-child(12) { animation-delay: 0.48s; }
|
||||
.tabela tbody tr:nth-child(13) { animation-delay: 0.52s; }
|
||||
.tabela tbody tr:nth-child(14) { animation-delay: 0.56s; }
|
||||
.tabela tbody tr:nth-child(15) { animation-delay: 0.60s; }
|
||||
.tabela tbody tr:nth-child(16) { animation-delay: 0.64s; }
|
||||
.tabela tbody tr:nth-child(17) { animation-delay: 0.68s; }
|
||||
.tabela tbody tr:nth-child(18) { animation-delay: 0.72s; }
|
||||
.tabela tbody tr:nth-child(19) { animation-delay: 0.76s; }
|
||||
.tabela tbody tr:nth-child(20) { animation-delay: 0.80s; }
|
||||
|
||||
/* content-visibility: auto – browser preskače render elemenata van viewport-a.
|
||||
Stranica je ograničena na 50 redova, pa redovi tabele ne koriste ovu optimizaciju:
|
||||
procenjena visina (contain-intrinsic-size) se nije poklapala sa stvarnom, pa su
|
||||
redovi „secali" pri skrolu kad ih browser tek tada izmeri. */
|
||||
[class*="-kartice"] > .animiraj,
|
||||
.klijenti-kartice > .animiraj {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 120px;
|
||||
}
|
||||
|
||||
/* Stagger mobilnih lista-kartica (parnjak gornjeg za tabele). Pogađa kartice
|
||||
unutar bilo kog .X-kartice kontejnera; ostali tipovi kartica (detalji/forma/
|
||||
@@ -1027,6 +1162,11 @@ select {
|
||||
[class*="-kartice"] > .animiraj:nth-child(3) { animation-delay: 0.16s; }
|
||||
[class*="-kartice"] > .animiraj:nth-child(4) { animation-delay: 0.22s; }
|
||||
[class*="-kartice"] > .animiraj:nth-child(5) { animation-delay: 0.28s; }
|
||||
[class*="-kartice"] > .animiraj:nth-child(6) { animation-delay: 0.34s; }
|
||||
[class*="-kartice"] > .animiraj:nth-child(7) { animation-delay: 0.40s; }
|
||||
[class*="-kartice"] > .animiraj:nth-child(8) { animation-delay: 0.46s; }
|
||||
[class*="-kartice"] > .animiraj:nth-child(9) { animation-delay: 0.52s; }
|
||||
[class*="-kartice"] > .animiraj:nth-child(10) { animation-delay: 0.58s; }
|
||||
|
||||
/* Stagger naslaganih .kartica.animiraj na stranicama podešavanja/profila — JEDNO mesto.
|
||||
Descendant selektor (razmak, ne >): nth-child se računa po neposrednom roditelju kao i
|
||||
@@ -1045,6 +1185,22 @@ select {
|
||||
.dash-stat.animiraj:nth-child(4) { animation-delay: 0.23s; }
|
||||
.dash-stat.animiraj:nth-child(5) { animation-delay: 0.28s; }
|
||||
|
||||
/* kartica koja je ujedno i link — zamena za inline text-decoration/display/cursor */
|
||||
.kartica-link { text-decoration: none; display: block; cursor: pointer; }
|
||||
|
||||
/* ikona wrapper na stat karticama dashboarda */
|
||||
.dash-ikona {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
transition: filter 0.25s;
|
||||
}
|
||||
.dash-ikona-plava { background: #eff2ff; }
|
||||
.dash-ikona-zelena { background: #f0fdf4; }
|
||||
.dash-ikona-narandzasta { background: #fff7ed; }
|
||||
.dash-ikona-crvena { background: #fef2f2; }
|
||||
.dash-ikona-nebo { background: #f0f9ff; }
|
||||
|
||||
/* Bedž statusa servisnog naloga — JEDNO mesto za izgled i boje statusa (lista i detalji).
|
||||
Mora biti u main.css: HTMX navigacija odbacuje <head>, pa page <style> ne bi važio. */
|
||||
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; white-space: nowrap; }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
--tekst-sporedni: #57606a;
|
||||
--tekst-jak: #1f2328;
|
||||
--ivica: #d0d7de;
|
||||
--ivica-jaka: #9ba5b0;
|
||||
--sb-akcent: #1f6feb;
|
||||
--sb-akcent-hover: #388bfd;
|
||||
--sb-aktivan: #ebeef1;
|
||||
@@ -15,7 +16,8 @@
|
||||
--upozorenje: #9a6700;
|
||||
--greska: #cf222e;
|
||||
--info: #0969da;
|
||||
--senka: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--senka: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
--senka-hover: 0 8px 28px rgba(0, 0, 0, 0.18), 0 2px 8px rgba(0, 0, 0, 0.10);
|
||||
--sidebar-pozadina: #ffffff;
|
||||
--poruka-uspeh-bg: rgba(26, 127, 55, 0.1);
|
||||
--poruka-uspeh-boja: #1a7f37;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
--tekst-sporedni: #a0a8b5;
|
||||
--tekst-jak: #f0f3f6;
|
||||
--ivica: #353a44;
|
||||
--ivica-jaka: #5a6070;
|
||||
--sb-akcent: #4684d6;
|
||||
--sb-akcent-hover: #5a96e6;
|
||||
--sb-aktivan: #2a2f37;
|
||||
@@ -15,8 +16,12 @@
|
||||
--upozorenje: #e1b04a;
|
||||
--greska: #cf5757;
|
||||
--info: #5cb1d6;
|
||||
--senka: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--senka: 0 4px 16px rgba(0, 0, 0, 0.55), 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
--senka-hover: 0 8px 28px rgba(0, 0, 0, 0.7), 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
--sidebar-pozadina: #1a1c20;
|
||||
--poruka-uspeh-bg: rgba(93, 184, 118, 0.15);
|
||||
--poruka-uspeh-boja: #5db876;
|
||||
}
|
||||
|
||||
.toast-uspeh { background: #1a2e22; color: #5db876; border: 0.5px solid #2d5a3d; }
|
||||
.toast-greska { background: #2e1a1a; color: #e07070; border: 0.5px solid #5a2d2d; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg width="100%" viewBox="0 0 750 690" role="img" style="" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 750 690" role="img" style="" xmlns="http://www.w3.org/2000/svg">
|
||||
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">NTech favicon v4</title>
|
||||
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Moderna ikonica sa NT slovima i tehnološkim akcentom</desc>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
+43
-2
@@ -181,6 +181,13 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
ukupnoSvega() {
|
||||
return this.stavke.reduce((z, s) => z + (parseFloat(s.kolicina) * parseFloat(s.cena) || 0), 0).toFixed(2)
|
||||
},
|
||||
// fmtDin formatira broj sa separatorom hiljada (tačka) i 2 decimale (zarez): 1234567.5 → "1.234.567,50"
|
||||
fmtDin(v) {
|
||||
const n = parseFloat(v) || 0
|
||||
const [ceo, dec] = Math.abs(n).toFixed(2).split('.')
|
||||
const saTackama = ceo.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
|
||||
return (n < 0 ? '-' : '') + saTackama + ',' + dec
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -188,6 +195,8 @@ document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('nabavkaForma', () => ({
|
||||
stavke: [{artikal_id: '', kolicina: 1, cena: 0, marza: 0, prodajna: 0}],
|
||||
artikliOpcije: [],
|
||||
dobavljacId: '', // izabrani dobavljač nabavke — filtrira listu artikala
|
||||
prikaziSveArtikle: false, // true = prikaži sve artikle, ne samo dobavljačeve
|
||||
marzaDefault: 0,
|
||||
troskovi: [], // zavisni troškovi {naziv, iznos}
|
||||
metodRaspodele: 'vrednost', // 'vrednost' ili 'kolicina'
|
||||
@@ -201,6 +210,7 @@ document.addEventListener('alpine:init', () => {
|
||||
modalOpis: '',
|
||||
modalKolicina: '',
|
||||
modalKolicinaMin: '',
|
||||
modalNabavnaCena: '',
|
||||
modalCena: '',
|
||||
modalLokacija: '',
|
||||
modalNapomena: '',
|
||||
@@ -225,9 +235,21 @@ document.addEventListener('alpine:init', () => {
|
||||
})
|
||||
},
|
||||
dodajStavku() {
|
||||
// ne dozvoli novu stavku dok poslednja nema izabran artikal
|
||||
const poslednja = this.stavke[this.stavke.length - 1]
|
||||
if (poslednja && !poslednja.artikal_id) {
|
||||
if (window.ntechToast) window.ntechToast('Prvo izaberi artikal u poslednjoj stavci.', 'greska')
|
||||
return
|
||||
}
|
||||
this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, marza: this.marzaDefault, prodajna: 0})
|
||||
this.preracunajSve()
|
||||
},
|
||||
// artikli za prikaz u stavkama: bez dobavljača (ili uz "prikaži sve") svi, inače samo njegovi
|
||||
artikliZaDobavljaca() {
|
||||
if (!this.dobavljacId || this.prikaziSveArtikle) return this.artikliOpcije
|
||||
const did = Number(this.dobavljacId)
|
||||
return this.artikliOpcije.filter(a => Array.isArray(a.dobavljaci) && a.dobavljaci.includes(did))
|
||||
},
|
||||
// PDV stopa izabranog artikla (iz JSON liste) — za obračun prodajne cene.
|
||||
// Ako firma nije PDV obveznik, PDV se ne dodaje na prodajnu cenu (stopa = 0).
|
||||
pdvStopa(artikalId) {
|
||||
@@ -239,11 +261,13 @@ document.addEventListener('alpine:init', () => {
|
||||
izaberiArtikal(s) {
|
||||
const a = this.artikliOpcije.find(x => String(x.id) === String(s.artikal_id))
|
||||
if (a) {
|
||||
if (a.nabavna_cena != null) s.cena = a.nabavna_cena
|
||||
if (a.marza != null) s.marza = a.marza
|
||||
else if (a.kategorija_marza != null) s.marza = a.kategorija_marza
|
||||
else s.marza = this.marzaDefault
|
||||
}
|
||||
this.izracunajProdajnu(s)
|
||||
this.preracunajSve()
|
||||
},
|
||||
// ukupan zavisni trošak nabavke
|
||||
ukupanTrosak() {
|
||||
@@ -296,7 +320,12 @@ document.addEventListener('alpine:init', () => {
|
||||
this.preracunajSve()
|
||||
},
|
||||
ukloniStavku(i) {
|
||||
if (this.stavke.length > 1) this.stavke.splice(i, 1)
|
||||
if (this.stavke.length > 1) {
|
||||
this.stavke.splice(i, 1)
|
||||
} else {
|
||||
// poslednja stavka — ne brišemo red nego ga resetujemo na prazno
|
||||
this.stavke[0] = {artikal_id: '', kolicina: 1, cena: 0, marza: this.marzaDefault, prodajna: 0}
|
||||
}
|
||||
this.preracunajSve()
|
||||
},
|
||||
ukupnoStavke(s) {
|
||||
@@ -305,6 +334,13 @@ document.addEventListener('alpine:init', () => {
|
||||
ukupnoSvega() {
|
||||
return this.stavke.reduce((z, s) => z + (parseFloat(s.kolicina) * parseFloat(s.cena) || 0), 0).toFixed(2)
|
||||
},
|
||||
// fmtDin formatira broj sa separatorom hiljada (tačka) i 2 decimale (zarez): 1234567.5 → "1.234.567,50"
|
||||
fmtDin(v) {
|
||||
const n = parseFloat(v) || 0
|
||||
const [ceo, dec] = Math.abs(n).toFixed(2).split('.')
|
||||
const saTackama = ceo.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
|
||||
return (n < 0 ? '-' : '') + saTackama + ',' + dec
|
||||
},
|
||||
otvoriModal() {
|
||||
this.modal = true
|
||||
this.modalGreska = ''
|
||||
@@ -313,6 +349,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.modalOpis = ''
|
||||
this.modalKolicina = ''
|
||||
this.modalKolicinaMin = ''
|
||||
this.modalNabavnaCena = ''
|
||||
this.modalCena = ''
|
||||
this.modalLokacija = ''
|
||||
this.modalNapomena = ''
|
||||
@@ -334,6 +371,7 @@ document.addEventListener('alpine:init', () => {
|
||||
params.append('opis', this.modalOpis.trim())
|
||||
if (this.modalKolicina) params.append('kolicina', this.modalKolicina)
|
||||
if (this.modalKolicinaMin) params.append('kolicina_min', this.modalKolicinaMin)
|
||||
if (this.modalNabavnaCena) params.append('nabavna_cena', this.modalNabavnaCena)
|
||||
if (this.modalCena) params.append('prodajna_cena', this.modalCena)
|
||||
params.append('lokacija', this.modalLokacija.trim())
|
||||
params.append('napomena', this.modalNapomena.trim())
|
||||
@@ -349,7 +387,10 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
const noviArtikal = await odgovor.json()
|
||||
this.artikliOpcije.push({id: noviArtikal.id, naziv: noviArtikal.naziv})
|
||||
// veži novi artikal za izabranog dobavljača da se odmah prikaže u filtriranoj listi
|
||||
// (veza se trajno upisuje u bazu pri čuvanju nabavke — auto-veza)
|
||||
const dob = this.dobavljacId ? [Number(this.dobavljacId)] : []
|
||||
this.artikliOpcije.push({id: noviArtikal.id, naziv: noviArtikal.naziv, dobavljaci: dob})
|
||||
this.zatvoriModal()
|
||||
} catch {
|
||||
this.modalGreska = 'Greška pri komunikaciji sa serverom.'
|
||||
|
||||
@@ -187,19 +187,19 @@
|
||||
{{if index .Dozvole "podesavanja.pregled"}}
|
||||
<div>
|
||||
<button type="button" data-podmeni-dugme
|
||||
class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "dozvole")}}aktivan{{end}}"
|
||||
class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}aktivan{{end}}"
|
||||
style="width:100%;background:none;border:none;cursor:pointer;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<span>Podešavanja</span>
|
||||
<span class="nav-strelica">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}">
|
||||
style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-tooltip">Podešavanja</span>
|
||||
</button>
|
||||
<div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "dozvole")}}otvoren{{end}}">
|
||||
<div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}otvoren{{end}}">
|
||||
<a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>
|
||||
<span>Opšte</span>
|
||||
@@ -220,6 +220,11 @@
|
||||
<span>Kalkulacija i PDV</span>
|
||||
<span class="nav-tooltip">Kalkulacija i PDV</span>
|
||||
</a>
|
||||
<a href="/admin/podesavanja/servis" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-servis"}}aktivan{{end}}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
<span>Servis</span>
|
||||
<span class="nav-tooltip">Servis</span>
|
||||
</a>
|
||||
{{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}}
|
||||
<a href="/admin/dozvole" class="nav-stavka nav-podstavka {{if eq .Stranica "dozvole"}}aktivan{{end}}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{if and .TopbarLogoSlika .LogoPutanja}}
|
||||
<img src="{{.LogoPutanja}}" alt="Logo" class="topbar-logo">
|
||||
{{if .LogoPutanja}}
|
||||
<img src="{{.LogoPutanja}}" alt="Logo" class="topbar-logo{{if not .TopbarLogoSlika}} skriven{{end}}">
|
||||
{{end}}
|
||||
|
||||
<span class="topbar-naslov">{{.NaslovStranice}}</span>
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<div class="kartica animiraj">
|
||||
<div style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni); margin-bottom: 16px; padding-bottom: 12px; border-bottom: 0.5px solid var(--ivica)">Promena lozinke</div>
|
||||
|
||||
{{if .JelDemo}}
|
||||
<p style="font-size: 13px; color: var(--tekst-sporedni)">Promena lozinke nije dostupna u demo modu.</p>
|
||||
{{else}}
|
||||
<form method="POST" action="/admin/profil/lozinka">
|
||||
<div style="display: flex; flex-direction: column; gap: 12px">
|
||||
<div>
|
||||
@@ -24,6 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- TOTP / 2FA -->
|
||||
@@ -93,9 +97,11 @@
|
||||
Status:
|
||||
<strong>Isključena</strong>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: var(--tekst-sporedni)">Preporučujemo uključivanje dvostepene verifikacije.</div>
|
||||
<div style="font-size: 13px; color: var(--tekst-sporedni)">
|
||||
{{if .JelDemo}}Podešavanje 2FA nije dostupno u demo modu.{{else}}Preporučujemo uključivanje dvostepene verifikacije.{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/profil/totp/pokreni" class="btn-primarno">Podesi 2FA</a>
|
||||
{{if not .JelDemo}}<a href="/admin/profil/totp/pokreni" class="btn-primarno">Podesi 2FA</a>{{end}}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
{{ end }}
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||
<!-- stat kartice -->
|
||||
<a href="/magacin" class="kartica dash-stat animiraj" style="text-decoration:none;display:block;cursor:pointer;">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:#eff2ff;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
|
||||
<a href="/magacin" class="kartica dash-stat animiraj kartica-link">
|
||||
<div class="dash-ikona dash-ikona-plava">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4f7ef8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
</svg>
|
||||
@@ -31,8 +31,8 @@
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Artikala na stanju</div>
|
||||
</a>
|
||||
|
||||
<a href="/servis" class="kartica dash-stat animiraj" style="text-decoration:none;display:block;cursor:pointer;">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:#f0fdf4;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
|
||||
<a href="/servis" class="kartica dash-stat animiraj kartica-link">
|
||||
<div class="dash-ikona dash-ikona-zelena">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
@@ -42,21 +42,21 @@
|
||||
</a>
|
||||
|
||||
{{ if index .Dozvole "dashboard.prihod" }}
|
||||
<a href="/izvestaji" class="kartica dash-stat animiraj" style="text-decoration:none;display:block;cursor:pointer;">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:#fff7ed;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
|
||||
<a href="/izvestaji" class="kartica dash-stat animiraj kartica-link">
|
||||
<div class="dash-ikona dash-ikona-narandzasta">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="21" r="1" />
|
||||
<circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);">{{ printf "%.0f" .PrihodOvogMeseca }} din</div>
|
||||
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);">{{ dinariCeli .PrihodOvogMeseca }} din</div>
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Prihod ovog meseca</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
|
||||
<a href="/magacin?kriticni=1" class="kartica dash-stat animiraj" style="text-decoration:none;display:block;cursor:pointer;">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:#fef2f2;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
|
||||
<a href="/magacin?kriticni=1" class="kartica dash-stat animiraj kartica-link">
|
||||
<div class="dash-ikona dash-ikona-crvena">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
@@ -67,8 +67,8 @@
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Kritično niska zaliha</div>
|
||||
</a>
|
||||
|
||||
<a href="/podsetnici" class="kartica dash-stat animiraj" style="text-decoration:none;display:block;cursor:pointer;">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:#f0f9ff;display:flex;align-items:center;justify-content:center;margin-bottom:10px;">
|
||||
<a href="/podsetnici" class="kartica dash-stat animiraj kartica-link">
|
||||
<div class="dash-ikona dash-ikona-nebo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0284c7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
@@ -126,7 +126,7 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
<div style="text-align:right;flex-shrink:0;">
|
||||
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{ printf "%.0f" .Ukupno }} din</div>
|
||||
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{ dinariCeli .Ukupno }} din</div>
|
||||
<div style="font-size:11px;color:var(--tekst-sporedni);margin-top:1px;">{{ .Datum }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,19 +12,21 @@
|
||||
<div class="poruka-uspeh poruka-animacija">Dobavljač je uspešno obrisan.</div>
|
||||
{{end}}
|
||||
|
||||
<!-- gornja traka: dugme + pretraga -->
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<!-- gornja traka: dugme + interaktivna pretraga -->
|
||||
<div id="dobavljaci-filteri" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<a href="/dobavljaci/novi" class="btn-primarno">+ Novi dobavljač</a>
|
||||
<form method="GET" action="/dobavljaci" style="display:flex;gap:8px;flex:1;min-width:200px;">
|
||||
<form method="GET" action="/dobavljaci" style="display:flex;gap:8px;flex:1;min-width:200px;"
|
||||
hx-get="/dobavljaci" hx-target="#dobavljaci-rezultati" hx-select="#dobavljaci-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
<input type="text" name="pretraga" value="{{.Pretraga}}"
|
||||
placeholder="Pretraži dobavljače..."
|
||||
style="flex:1;">
|
||||
<button type="submit" class="btn-primarno">
|
||||
Traži
|
||||
</button>
|
||||
style="flex:1;"
|
||||
hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search"
|
||||
hx-get="/dobavljaci" hx-target="#dobavljaci-rezultati" hx-select="#dobavljaci-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="dobavljaci-rezultati">
|
||||
|
||||
<!-- desktop tabela -->
|
||||
<div class="kartica dobavljaci-tabela animiraj" style="padding:0;overflow:hidden;">
|
||||
<div style="overflow-x:auto;">
|
||||
@@ -46,7 +48,7 @@
|
||||
{{if .KontaktOsoba}}{{.KontaktOsoba}}{{else}}—{{end}}
|
||||
</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if .Telefon}}{{.Telefon}}{{else}}—{{end}}
|
||||
{{if .Telefon}}{{telefon .Telefon}}{{else}}—{{end}}
|
||||
</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if .Email}}{{.Email}}{{else}}—{{end}}
|
||||
@@ -70,7 +72,7 @@
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
|
||||
Nema dobavljača. <a href="/dobavljaci/novi" style="color:var(--sb-akcent);">Dodaj prvog dobavljača.</a>
|
||||
{{if $.Pretraga}}Za datu pretragu nema dobavljača u bazi.{{else}}Nema dobavljača. <a href="/dobavljaci/novi" style="color:var(--sb-akcent);">Dodaj prvog dobavljača.</a>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -107,7 +109,7 @@
|
||||
{{end}}
|
||||
{{if .Telefon}}
|
||||
<div class="pomocni-tekst">
|
||||
<span style="color:var(--tekst-glavni);font-weight:500;">Telefon:</span> {{.Telefon}}
|
||||
<span style="color:var(--tekst-glavni);font-weight:500;">Telefon:</span> {{telefon .Telefon}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Email}}
|
||||
@@ -122,10 +124,33 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
|
||||
Nema dobavljača. <a href="/dobavljaci/novi" style="color:var(--sb-akcent);">Dodaj prvog dobavljača.</a>
|
||||
{{if $.Pretraga}}Za datu pretragu nema dobavljača u bazi.{{else}}Nema dobavljača. <a href="/dobavljaci/novi" style="color:var(--sb-akcent);">Dodaj prvog dobavljača.</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div><!-- kraj #dobavljaci-rezultati -->
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Gasi animaciju redova pri pretrazi.
|
||||
document.body.addEventListener('htmx:beforeRequest', function (e) {
|
||||
var elt = e.detail && e.detail.elt;
|
||||
if (elt && elt.name === 'pretraga') {
|
||||
var rez = document.getElementById('dobavljaci-rezultati');
|
||||
if (rez) rez.classList.add('bez-anim');
|
||||
}
|
||||
});
|
||||
// Reflow pri sidebar navigaciji da nema bljeska animacije.
|
||||
document.body.addEventListener('htmx:afterSwap', function (e) {
|
||||
var t = e.detail && e.detail.target;
|
||||
if (t && t.id === 'dobavljaci-rezultati') return;
|
||||
var rez = document.getElementById('dobavljaci-rezultati');
|
||||
if (!rez) return;
|
||||
rez.classList.add('bez-anim');
|
||||
void rez.offsetHeight;
|
||||
rez.classList.remove('bez-anim');
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -6,12 +6,6 @@
|
||||
<style>
|
||||
.izv-naslov { font-size: 13px; font-weight: 500; color: var(--tekst-sporedni); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 14px; }
|
||||
.toggle-red { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||
.toggle-switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; inset: 0; background: var(--ivica); border-radius: 22px; cursor: pointer; transition: background 0.2s; }
|
||||
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: transform 0.2s; }
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--sb-akcent); }
|
||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(18px); }
|
||||
.rang-broj { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: var(--pozadina); font-size: 11px; font-weight: 600; color: var(--tekst-sporedni); flex-shrink: 0; }
|
||||
.rang-broj.zlatni { background: #fef3c7; color: #92400e; }
|
||||
.badge-dana { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 500; white-space: nowrap; }
|
||||
@@ -28,15 +22,22 @@
|
||||
{{define "sadrzaj"}}
|
||||
<div class="kolona" style="gap:20px;">
|
||||
|
||||
<!-- brzi linkovi ka magacinskim izveštajima -->
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<a href="/izvestaji/prometni-list" class="btn-sekundarno">Prometni list magacina</a>
|
||||
<a href="/izvestaji/stanje-zaliha" class="btn-sekundarno">Stanje zaliha</a>
|
||||
<a href="/izvestaji/popis" class="btn-sekundarno">Popis (inventura)</a>
|
||||
</div>
|
||||
|
||||
<!-- 1. mesečni prihod -->
|
||||
<div class="kartica izv-sekcija animiraj">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:4px;">
|
||||
<div class="izv-naslov" style="margin-bottom:0;">Mesečni prihod — poslednjih 12 meseci</div>
|
||||
<div class="toggle-red" style="margin-bottom:0;">
|
||||
<span class="pomocni-tekst">Grafikon</span>
|
||||
<label class="toggle-switch">
|
||||
<label class="toggl">
|
||||
<input type="checkbox" id="toggle-grafikon" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggl-klizac"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,13 +61,13 @@
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">{{.MesecPrikaz}}</td>
|
||||
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if gt .Prodaja 0.0}}{{printf "%.0f" .Prodaja}} din{{else}}—{{end}}
|
||||
{{if gt .Prodaja 0.0}}{{dinariCeli .Prodaja}} din{{else}}—{{end}}
|
||||
</td>
|
||||
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if gt .Servis 0.0}}{{printf "%.0f" .Servis}} din{{else}}—{{end}}
|
||||
{{if gt .Servis 0.0}}{{dinariCeli .Servis}} din{{else}}—{{end}}
|
||||
</td>
|
||||
<td style="padding:8px 12px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{if gt .Ukupno 0.0}}{{printf "%.0f" .Ukupno}} din{{else}}—{{end}}
|
||||
{{if gt .Ukupno 0.0}}{{dinariCeli .Ukupno}} din{{else}}—{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -144,7 +145,7 @@
|
||||
<div style="font-size:11px;color:var(--tekst-sporedni);margin-top:1px;">{{.Kategorija}}</div>
|
||||
</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-size:13px;color:var(--tekst-glavni);">{{.UkupnoKolicina}}</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.0f" .UkupnoPrihod}} din</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{dinariCeli .UkupnoPrihod}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
@@ -175,7 +176,7 @@
|
||||
</td>
|
||||
<td style="padding:7px 8px;font-size:13px;color:var(--tekst-glavni);">{{.Naziv}}</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">{{.BrojNaloga}}</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.0f" .UkupnoVrednost}} din</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{dinariCeli .UkupnoVrednost}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
<input type="text" name="naziv" placeholder="npr. Memorija, Diskovi, Kablovi..."
|
||||
style="width:100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Kôd (prefiks šifre)</label>
|
||||
<input type="text" name="kod" placeholder="npr. MEM (daje šifre MEM-0001)"
|
||||
maxlength="10" style="width:100%;text-transform:uppercase;">
|
||||
<div class="pomocni-tekst" style="margin-top:4px;">Samo slova i brojevi. Prazno = šifre kreću sa ART-.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Opis</label>
|
||||
<input type="text" name="opis" placeholder="Kratak opis kategorije..."
|
||||
@@ -76,7 +82,10 @@
|
||||
{{range .Kategorije}}
|
||||
<div class="kat-red animiraj" style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:0.5px solid var(--ivica);">
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</div>
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);">
|
||||
{{.Naziv}}
|
||||
{{if .Kod}}<span style="font-size:11px;font-family:monospace;color:var(--tekst-sporedni);background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:4px;padding:1px 6px;margin-left:6px;">{{.Kod}}</span>{{end}}
|
||||
</div>
|
||||
{{if .Opis}}
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.Opis}}</div>
|
||||
{{end}}
|
||||
@@ -98,6 +107,13 @@
|
||||
<input type="text" name="naziv" value="{{.Naziv}}" required
|
||||
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Kôd (prefiks šifre)</label>
|
||||
<input type="text" name="kod" value="{{.Kod}}" maxlength="10"
|
||||
placeholder="npr. MEM"
|
||||
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;text-transform:uppercase;">
|
||||
{{if not .Kod}}<div class="pomocni-tekst" style="margin-top:4px;color:#f59e0b;">Kôd nije postavljen — artikli će koristiti prefiks ART-.</div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Opis</label>
|
||||
<input type="text" name="opis" value="{{.Opis}}"
|
||||
|
||||
@@ -12,19 +12,40 @@
|
||||
<div class="poruka-uspeh poruka-animacija">Klijent je uspešno obrisan.</div>
|
||||
{{end}}
|
||||
|
||||
<!-- gornja traka: dugme + pretraga -->
|
||||
<!-- gornja traka: dugme + interaktivna pretraga -->
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<a href="/klijenti/novi" class="btn-primarno">+ Novi klijent</a>
|
||||
<form method="GET" action="/klijenti" style="display:flex;gap:8px;flex:1;min-width:200px;">
|
||||
<form method="GET" action="/klijenti" style="display:flex;gap:8px;flex:1;min-width:200px;"
|
||||
hx-get="/klijenti" hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
<input type="text" name="pretraga" value="{{.Pretraga}}"
|
||||
placeholder="Pretraži po imenu ili nazivu firme..."
|
||||
style="flex:1;">
|
||||
<button type="submit" class="btn-primarno">
|
||||
Traži
|
||||
</button>
|
||||
style="flex:1;"
|
||||
hx-trigger="keyup changed delay:300ms, search"
|
||||
hx-get="/klijenti" hx-include="[name='tip']:checked" hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="klijenti-rezultati">
|
||||
|
||||
<!-- filter po tipu klijenta -->
|
||||
<div style="display:flex;gap:16px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
|
||||
<input type="radio" name="tip" value="" {{if eq .TipFilter ""}}checked{{end}}
|
||||
hx-trigger="change" hx-get="/klijenti" hx-include="[name='pretraga']"
|
||||
hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true"> Svi
|
||||
</label>
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
|
||||
<input type="radio" name="tip" value="pravno" {{if eq .TipFilter "pravno"}}checked{{end}}
|
||||
hx-trigger="change" hx-get="/klijenti" hx-include="[name='pretraga']"
|
||||
hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true"> Firme
|
||||
</label>
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;">
|
||||
<input type="radio" name="tip" value="fizicko" {{if eq .TipFilter "fizicko"}}checked{{end}}
|
||||
hx-trigger="change" hx-get="/klijenti" hx-include="[name='pretraga']"
|
||||
hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true"> Fizička lica
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- desktop tabela -->
|
||||
<div class="kartica klijenti-tabela kartica-tabela animiraj">
|
||||
<div class="tabela-skrol">
|
||||
@@ -52,7 +73,7 @@
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="pomocni-tekst">
|
||||
{{if .Telefon}}{{.Telefon}}{{else}}—{{end}}
|
||||
{{if .Telefon}}{{telefon .Telefon}}{{else}}—{{end}}
|
||||
</td>
|
||||
<td class="pomocni-tekst">
|
||||
{{if .Email}}{{.Email}}{{else}}—{{end}}
|
||||
@@ -79,7 +100,7 @@
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="prazno-stanje">
|
||||
Nema klijenata. <a href="/klijenti/novi" style="color:var(--sb-akcent);">Dodaj prvog klijenta.</a>
|
||||
{{if $.Pretraga}}Za datu pretragu nema klijenata u bazi.{{else}}Nema klijenata. <a href="/klijenti/novi" style="color:var(--sb-akcent);">Dodaj prvog klijenta.</a>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -120,7 +141,7 @@
|
||||
<div class="kolona" style="gap:6px;">
|
||||
{{if .Telefon}}
|
||||
<div class="pomocni-tekst">
|
||||
<span style="color:var(--tekst-glavni);font-weight:500;">Telefon:</span> {{.Telefon}}
|
||||
<span style="color:var(--tekst-glavni);font-weight:500;">Telefon:</span> {{telefon .Telefon}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Email}}
|
||||
@@ -135,10 +156,38 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="prazno-stanje">
|
||||
Nema klijenata. <a href="/klijenti/novi" style="color:var(--sb-akcent);">Dodaj prvog klijenta.</a>
|
||||
{{if $.Pretraga}}Za datu pretragu nema klijenata u bazi.{{else}}Nema klijenata. <a href="/klijenti/novi" style="color:var(--sb-akcent);">Dodaj prvog klijenta.</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- paginacija -->
|
||||
{{if gt .UkupnoStranica 1}}
|
||||
<div class="paginacija" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:12px 0;flex-wrap:wrap;">
|
||||
<span style="font-size:13px;color:var(--tekst-sporedni);">{{.UkupnoKlijenata}} klijenata — {{.StranicaBr}}/{{.UkupnoStranica}}</span>
|
||||
<div style="display:flex;gap:4px;">
|
||||
{{if gt .StranicaBr 1}}
|
||||
<a href="?stranica={{.StranicaPrev}}{{.StranicaQueryUrl}}" class="btn-sekundarno-malo" hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true">← Prethodna</a>
|
||||
{{end}}
|
||||
{{if lt .StranicaBr .UkupnoStranica}}
|
||||
<a href="?stranica={{.StranicaNext}}{{.StranicaQueryUrl}}" class="btn-primarno-malo" hx-target="#klijenti-rezultati" hx-select="#klijenti-rezultati" hx-swap="innerHTML" hx-push-url="true">Sledeća →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div><!-- kraj #klijenti-rezultati -->
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Gasi animaciju redova pri pretrazi i promeni tipa (ali NE pri paginaciji).
|
||||
document.body.addEventListener('htmx:beforeRequest', function (e) {
|
||||
var elt = e.detail && e.detail.elt;
|
||||
if (elt && (elt.name === 'pretraga' || elt.name === 'tip')) {
|
||||
var rez = document.getElementById('klijenti-rezultati');
|
||||
if (rez) rez.classList.add('bez-anim');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
{{if .Obrisan}}
|
||||
<div class="poruka-uspeh">Artikal je uspešno obrisan.</div>
|
||||
{{end}}
|
||||
{{if .Arhiviran}}
|
||||
<div class="poruka-uspeh">Artikal je u prometu pa je arhiviran umesto obrisan. Možete ga pronaći među arhiviranim artiklima.</div>
|
||||
{{end}}
|
||||
{{if .Vracen}}
|
||||
<div class="poruka-uspeh">Artikal je vraćen u aktivnu listu.</div>
|
||||
{{end}}
|
||||
{{if .Greska}}
|
||||
<div class="poruka-greska">Operacija nije uspela. Pokušajte ponovo.</div>
|
||||
{{end}}
|
||||
{{if .Premesten}}
|
||||
<div class="poruka-uspeh">Artikal je premešten u drugu kategoriju.</div>
|
||||
{{end}}
|
||||
@@ -25,34 +34,48 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- pretraga i filteri -->
|
||||
<form method="GET" action="/magacin" class="kolona" style="gap:10px;">
|
||||
<!-- pretraga i filteri — interaktivna pretraga (hx-trigger) -->
|
||||
<form method="GET" action="/magacin" class="kolona" style="gap:10px;" id="magacin-filteri"
|
||||
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
<input type="text" name="pretraga" value="{{.Filter.Pretraga}}"
|
||||
placeholder="Pretraži artikle..."
|
||||
style="width:100%;">
|
||||
placeholder="Pretraži po nazivu, šifri, barkodu, lokaciji..."
|
||||
style="width:100%;"
|
||||
hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search"
|
||||
hx-get="/magacin" hx-include="closest form" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<select name="kategorija" style="flex:1;min-width:140px;">
|
||||
<select name="kategorija" style="flex:1;min-width:140px;"
|
||||
hx-trigger="change"
|
||||
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
<option value="">Sve kategorije</option>
|
||||
{{range .Kategorije}}
|
||||
<option value="{{.ID}}" {{if eq (printf "%d" .ID) $.KategorijaIDStr}}selected{{end}}>{{.Naziv}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;background:var(--kartica);white-space:nowrap;">
|
||||
<input type="checkbox" name="kriticni" value="1" {{if .Filter.SamoKriticni}}checked{{end}}>
|
||||
<input type="checkbox" name="kriticni" value="1" {{if .Filter.SamoKriticni}}checked{{end}}
|
||||
hx-trigger="change"
|
||||
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
Samo kritični
|
||||
</label>
|
||||
<button type="submit" class="btn-primarno">
|
||||
Traži
|
||||
</button>
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;background:var(--kartica);white-space:nowrap;">
|
||||
<input type="checkbox" name="arhivirani" value="1" {{if .PrikazArhivirani}}checked{{end}}
|
||||
hx-trigger="change"
|
||||
hx-get="/magacin" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
|
||||
Arhivirani
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- rezultati pretrage — HTMX zamenjuje samo ovo, polje za pretragu ostaje u fokusu -->
|
||||
<div id="magacin-rezultati">
|
||||
|
||||
<!-- tabela artikala -->
|
||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="magacin-tabela tabela">
|
||||
<thead>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Šifra</th>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Naziv</th>
|
||||
<th style="padding:12px 16px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-jak);">Kategorija</th>
|
||||
<th style="padding:12px 16px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-jak);">Količina</th>
|
||||
@@ -64,16 +87,21 @@
|
||||
<tbody>
|
||||
{{range .Artikli}}
|
||||
<tr class="animiraj red-tabele">
|
||||
<td style="padding:12px 16px;font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</td>
|
||||
<td style="padding:12px 16px;font-size:13px;font-family:monospace;white-space:nowrap;">{{if .Sifra}}<a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="color:var(--tekst-sporedni);">{{.Sifra}}</a>{{else}}—{{end}}</td>
|
||||
<td style="padding:12px 16px;font-size:14px;"><a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="color:var(--tekst-glavni);font-weight:500;">{{.Naziv}}</a></td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}
|
||||
</td>
|
||||
<td style="padding:12px 16px;text-align:center;">
|
||||
{{if .PratiLager}}
|
||||
<span style="font-size:14px;font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">
|
||||
{{.Kolicina}}
|
||||
</span>
|
||||
{{else}}
|
||||
<span style="font-size:12px;color:var(--tekst-sporedni);">{{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{printf "%.0f" .ProdajnaCena}} din</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{dinariCeli .ProdajnaCena}} din</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if .Lokacija}}{{.Lokacija}}{{else}}—{{end}}
|
||||
</td>
|
||||
@@ -89,18 +117,25 @@
|
||||
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "tab")}}
|
||||
{{end}}{{end}}
|
||||
{{if index $.Dozvole "artikal.obrisi"}}
|
||||
{{if $.PrikazArhivirani}}
|
||||
<a href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo"
|
||||
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
|
||||
Vrati
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
|
||||
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
|
||||
Obriši
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
|
||||
Nema artikala. <a href="/magacin/novi" style="color:var(--sb-akcent);">Dodaj prvi artikal.</a>
|
||||
{{if or $.Filter.Pretraga $.KategorijaIDStr $.Filter.SamoKriticni}}Za datu pretragu nema artikala u bazi.{{else}}Nema artikala. <a href="/magacin/novi" style="color:var(--sb-akcent);">Dodaj prvi artikal.</a>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -115,7 +150,10 @@
|
||||
<div class="kartica magacin-kartica animiraj">
|
||||
<div class="red-izmedju" style="align-items:flex-start;gap:12px;margin-bottom:10px;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</div>
|
||||
<a href="/magacin/kartica/{{.ID}}" class="link-naziv" style="display:inline-block;font-size:15px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</a>
|
||||
{{if .Sifra}}
|
||||
<div style="font-size:12px;font-family:monospace;color:var(--tekst-sporedni);margin-top:2px;">{{.Sifra}}</div>
|
||||
{{end}}
|
||||
{{if .KategorijaNaziv}}
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">{{.KategorijaNaziv}}</div>
|
||||
{{end}}
|
||||
@@ -129,20 +167,32 @@
|
||||
{{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije "Prefiks" "kart")}}
|
||||
{{end}}{{end}}
|
||||
{{if index $.Dozvole "artikal.obrisi"}}
|
||||
{{if $.PrikazArhivirani}}
|
||||
<a href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo"
|
||||
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
|
||||
Vrati
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
|
||||
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
|
||||
Obriši
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:10px;">
|
||||
<div class="pomocni-tekst">
|
||||
{{if .PratiLager}}
|
||||
<span style="color:var(--tekst-glavni);font-weight:500;">Količina:</span>
|
||||
<span style="font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">{{.Kolicina}}</span>
|
||||
<span style="font-weight:500;color:{{if .KriticnaZaliha}}#dc2626{{else}}#16a34a{{end}};">{{.Kolicina}} {{.JedinicaMere}}</span>
|
||||
{{else}}
|
||||
<span style="color:var(--tekst-glavni);font-weight:500;">Tip:</span>
|
||||
<span style="color:var(--tekst-sporedni);">{{if eq .Tip "usluga"}}usluga{{else}}trošak{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="pomocni-tekst">
|
||||
<span style="color:var(--tekst-glavni);font-weight:500;">Cena:</span> {{printf "%.0f" .ProdajnaCena}} din
|
||||
<span style="color:var(--tekst-glavni);font-weight:500;">Cena:</span> {{dinariCeli .ProdajnaCena}} din
|
||||
</div>
|
||||
{{if .Lokacija}}
|
||||
<div class="pomocni-tekst">
|
||||
@@ -153,12 +203,57 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
|
||||
Nema artikala. <a href="/magacin/novi" style="color:var(--sb-akcent);">Dodaj prvi artikal.</a>
|
||||
{{if or $.Filter.Pretraga $.KategorijaIDStr $.Filter.SamoKriticni}}Za datu pretragu nema artikala u bazi.{{else}}Nema artikala. <a href="/magacin/novi" style="color:var(--sb-akcent);">Dodaj prvi artikal.</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- paginacija -->
|
||||
{{if gt .UkupnoStranica 1}}
|
||||
<div class="paginacija" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:12px 0;flex-wrap:wrap;">
|
||||
<span style="font-size:13px;color:var(--tekst-sporedni);">{{.UkupnoArtikala}} artikala — {{.StranicaBr}}/{{.UkupnoStranica}}</span>
|
||||
<div style="display:flex;gap:4px;">
|
||||
{{if gt .StranicaBr 1}}
|
||||
<a href="?stranica={{.StranicaPrev}}{{.StranicaQueryUrl}}" class="btn-sekundarno-malo" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">← Prethodna</a>
|
||||
{{end}}
|
||||
{{if lt .StranicaBr .UkupnoStranica}}
|
||||
<a href="?stranica={{.StranicaNext}}{{.StranicaQueryUrl}}" class="btn-primarno-malo" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">Sledeća →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div><!-- kraj #magacin-rezultati -->
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Gasi stagger animaciju redova pri pretrazi/filtriranju (ali NE pri paginaciji).
|
||||
// hx-on atribut ne radi pouzdano u ovoj htmx verziji, pa koristimo globalni listener:
|
||||
// kada zahtev kreće sa elementa unutar #magacin-filteri, dodamo .bez-anim na kontejner
|
||||
// rezultata. Pošto se menja samo innerHTML kontejnera, klasa ostaje i kroz naredne zamene.
|
||||
document.body.addEventListener('htmx:beforeRequest', function (e) {
|
||||
var elt = e.detail && e.detail.elt;
|
||||
if (elt && elt.closest && elt.closest('#magacin-filteri')) {
|
||||
var rez = document.getElementById('magacin-rezultati');
|
||||
if (rez) rez.classList.add('bez-anim');
|
||||
}
|
||||
});
|
||||
|
||||
// Pri sidebar navigaciji (boost menja ceo #glavni-sadrzaj) browser nakratko
|
||||
// prikaže redove pre nego što animacija krene. Forsiramo reflow: prvo ugasimo
|
||||
// animaciju, pa je odmah pustimo iz from-stanja → nema bljeska. Pretragu/
|
||||
// paginaciju (target = #magacin-rezultati) NE diramo.
|
||||
document.body.addEventListener('htmx:afterSwap', function (e) {
|
||||
var t = e.detail && e.detail.target;
|
||||
if (t && t.id === 'magacin-rezultati') return;
|
||||
var rez = document.getElementById('magacin-rezultati');
|
||||
if (!rez) return;
|
||||
rez.classList.add('bez-anim');
|
||||
void rez.offsetHeight;
|
||||
rez.classList.remove('bez-anim');
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{/* padajući meni za premeštanje artikla — prima dict {ID, Kategorije}; koristi se i u tabeli i u mobilnoj kartici */}}
|
||||
@@ -192,7 +287,7 @@
|
||||
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">×</button>
|
||||
</form>
|
||||
<form method="POST" action="/magacin/promeni-cenu/{{.ID}}" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
|
||||
<div style="font-size:13px;color:var(--tekst-sporedni);">Trenutna cena: <strong>{{printf "%.0f" .Cena}} din</strong></div>
|
||||
<div style="font-size:13px;color:var(--tekst-sporedni);">Trenutna cena: <strong>{{dinariCeli .Cena}} din</strong></div>
|
||||
<div>
|
||||
<label class="polje-labela">Nova cena (din)</label>
|
||||
<input type="number" name="nova_cena" min="0" step="0.01" value="{{printf "%.2f" .Cena}}" required
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
<form method="POST" action="{{if .Izmena}}/magacin/izmeni/{{.Artikal.ID}}{{else}}/magacin/novi{{end}}">
|
||||
<div class="kolona" style="gap:14px;">
|
||||
|
||||
<!-- šifra i barkod -->
|
||||
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<label class="polje-labela">Šifra artikla</label>
|
||||
<input type="text" name="sifra" id="sifra-input" value="{{.Artikal.Sifra}}"
|
||||
placeholder="npr. KOMP-0001"
|
||||
style="width:100%;font-family:monospace;">
|
||||
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Ako ostaviš prazno, šifra se automatski dodeljuje.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Barkod (EAN)</label>
|
||||
<input type="text" name="barkod" value="{{.Artikal.Barkod}}"
|
||||
placeholder="npr. 3830057592015"
|
||||
style="width:100%;font-family:monospace;">
|
||||
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:4px;">Barkod sa pakovanja (opciono).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- naziv -->
|
||||
<div>
|
||||
<label class="polje-labela">
|
||||
@@ -35,6 +53,32 @@
|
||||
style="width:100%;">
|
||||
</div>
|
||||
|
||||
<!-- tip i jedinica mere -->
|
||||
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<label class="polje-labela">Tip artikla</label>
|
||||
<select name="tip" id="tip-artikla" style="width:100%;">
|
||||
<option value="proizvod" {{if eq .Artikal.Tip "proizvod"}}selected{{end}}>Proizvod (prati lager)</option>
|
||||
<option value="usluga" {{if eq .Artikal.Tip "usluga"}}selected{{end}}>Usluga</option>
|
||||
<option value="trosak" {{if eq .Artikal.Tip "trosak"}}selected{{end}}>Trošak</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Jedinica mere</label>
|
||||
{{$jm := .Artikal.JedinicaMere}}
|
||||
<select name="jedinica_mere" style="width:100%;">
|
||||
<option value="kom" {{if eq $jm "kom"}}selected{{end}}>kom</option>
|
||||
<option value="sat" {{if eq $jm "sat"}}selected{{end}}>sat</option>
|
||||
<option value="set" {{if eq $jm "set"}}selected{{end}}>set</option>
|
||||
<option value="m" {{if eq $jm "m"}}selected{{end}}>m</option>
|
||||
<option value="m2" {{if eq $jm "m2"}}selected{{end}}>m²</option>
|
||||
<option value="l" {{if eq $jm "l"}}selected{{end}}>l</option>
|
||||
<option value="kg" {{if eq $jm "kg"}}selected{{end}}>kg</option>
|
||||
<option value="pak" {{if eq $jm "pak"}}selected{{end}}>pak</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kategorija -->
|
||||
<div>
|
||||
<label class="polje-labela">Kategorija</label>
|
||||
@@ -54,8 +98,8 @@
|
||||
style="width:100%;resize:vertical;">{{.Artikal.Opis}}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- količina i minimum -->
|
||||
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<!-- količina i minimum (samo za proizvode) -->
|
||||
<div class="forma-grid-2" data-lager style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<label class="polje-labela">Količina na stanju</label>
|
||||
<input type="number" name="kolicina" value="{{.Artikal.Kolicina}}" min="0" style="width:100%;">
|
||||
@@ -66,9 +110,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="polje-labela">Prodajna cena (din)</label>
|
||||
<input type="number" name="prodajna_cena" value="{{.Artikal.ProdajnaCena}}" min="0" step="0.01" style="width:100%;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<label class="polje-labela">Nabavna cena (din)</label>
|
||||
<input type="number" name="nabavna_cena" value="{{.Artikal.NabavnaCena}}" min="0" step="0.01" style="width:100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Prodajna cena (din)</label>
|
||||
<input type="number" name="prodajna_cena" value="{{.Artikal.ProdajnaCena}}" min="0" step="0.01" style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- marža za kalkulaciju; prazno = nasleđuje maržu kategorije ili globalnu -->
|
||||
@@ -78,8 +128,24 @@
|
||||
placeholder="prazno = po kategoriji / globalna">
|
||||
</div>
|
||||
|
||||
<!-- lokacija -->
|
||||
<!-- dobavljači koji isporučuju artikal -->
|
||||
{{if .Dobavljaci}}
|
||||
<div>
|
||||
<label class="polje-labela">Dobavljači</label>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:6px;padding:10px 12px;border:0.5px solid var(--ivica);border-radius:8px;">
|
||||
{{range .Dobavljaci}}
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;padding:4px 2px;">
|
||||
<input type="checkbox" name="dobavljaci" value="{{.ID}}" {{if index $.IzabraniDobavljaci .ID}}checked{{end}} style="flex-shrink:0;">
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{.Naziv}}</span>
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="pomocni-tekst" style="margin-top:4px;">Određuje koje artikle dobavljač nudi pri nabavci.</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- lokacija (samo za proizvode) -->
|
||||
<div data-lager>
|
||||
<label class="polje-labela">Lokacija u magacinu</label>
|
||||
<input type="text" name="lokacija" value="{{.Artikal.Lokacija}}"
|
||||
placeholder="npr. Polica A3, Kutija 2..."
|
||||
@@ -106,4 +172,56 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// sakrivanje polja lagera za usluge i troškove
|
||||
var tip = document.getElementById('tip-artikla');
|
||||
var lager = document.querySelectorAll('[data-lager]');
|
||||
function azurirajLager() {
|
||||
var prati = tip.value === 'proizvod';
|
||||
lager.forEach(function (el) { el.style.display = prati ? '' : 'none'; });
|
||||
}
|
||||
tip.addEventListener('change', azurirajLager);
|
||||
azurirajLager();
|
||||
|
||||
// auto-predlog šifre pri promeni kategorije (samo za nov artikal i ako šifra nije ručno menjana)
|
||||
var kat = document.querySelector('select[name="kategorija_id"]');
|
||||
var sifra = document.getElementById('sifra-input');
|
||||
var jeIzmena = {{if .Izmena}}true{{else}}false{{end}};
|
||||
if (sifra) {
|
||||
sifra.addEventListener('input', function () { sifra.dataset.rucno = '1'; });
|
||||
}
|
||||
if (kat && sifra && !jeIzmena) {
|
||||
kat.addEventListener('change', function () {
|
||||
if (sifra.dataset.rucno === '1' && sifra.value !== '') return;
|
||||
fetch('/magacin/sledeca-sifra?kategorija=' + encodeURIComponent(kat.value))
|
||||
.then(function (r) { return r.ok ? r.text() : ''; })
|
||||
.then(function (t) { if (t) sifra.value = t; });
|
||||
});
|
||||
}
|
||||
|
||||
// dvosmerno povezivanje: nabavna + marža → prodajna, i prodajna → marža
|
||||
var nabavna = document.querySelector('[name="nabavna_cena"]');
|
||||
var prodajna = document.querySelector('[name="prodajna_cena"]');
|
||||
var marza = document.querySelector('[name="marza"]');
|
||||
function broj(el) { return parseFloat(el.value) || 0; }
|
||||
// postavljanje .value programski ne okida 'input', pa nema beskonačne petlje
|
||||
function izProdajne() {
|
||||
var n = broj(nabavna);
|
||||
if (n <= 0) return;
|
||||
prodajna.value = (n * (1 + broj(marza) / 100)).toFixed(2);
|
||||
}
|
||||
function izMarze() {
|
||||
var n = broj(nabavna), p = broj(prodajna);
|
||||
if (n <= 0) { marza.value = ''; return; }
|
||||
marza.value = ((p / n - 1) * 100).toFixed(2);
|
||||
}
|
||||
if (nabavna && prodajna && marza) {
|
||||
marza.addEventListener('input', izProdajne);
|
||||
nabavna.addEventListener('input', izProdajne);
|
||||
prodajna.addEventListener('input', izMarze);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "naslov"}}Kartica: {{.Artikal.Naziv}} — NTech{{end}}
|
||||
|
||||
{{define "sadrzaj"}}
|
||||
<div class="kolona" style="gap:16px;">
|
||||
|
||||
<a href="/magacin" class="nazad-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
Nazad na magacin
|
||||
</a>
|
||||
|
||||
<!-- zaglavlje artikla -->
|
||||
<div class="kartica animiraj">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{.Artikal.Naziv}}</div>
|
||||
<div style="margin-top:6px;display:flex;gap:16px;flex-wrap:wrap;">
|
||||
{{if .Artikal.Sifra}}
|
||||
<span style="font-size:12px;color:var(--tekst-slabi);">Šifra: <span style="font-family:monospace;color:var(--tekst-glavni);">{{.Artikal.Sifra}}</span></span>
|
||||
{{end}}
|
||||
{{if .Artikal.Barkod}}
|
||||
<span style="font-size:12px;color:var(--tekst-slabi);">Barkod: <span style="font-family:monospace;color:var(--tekst-glavni);">{{.Artikal.Barkod}}</span></span>
|
||||
{{end}}
|
||||
{{if .Artikal.Lokacija}}
|
||||
<span style="font-size:12px;color:var(--tekst-slabi);">Lokacija: <span style="color:var(--tekst-glavni);">{{.Artikal.Lokacija}}</span></span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<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}};">
|
||||
{{.Artikal.Kolicina}} kom
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--tekst-slabi);">trenutno stanje</div>
|
||||
{{if gt .Artikal.KolicinMin 0}}
|
||||
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:2px;">min. {{.Artikal.KolicinMin}} kom</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- dobavljači artikla -->
|
||||
<div class="kartica animiraj">
|
||||
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-bottom:14px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);">
|
||||
Dobavljači
|
||||
<span style="font-size:12px;font-weight:400;color:var(--tekst-slabi);margin-left:8px;">{{len .Dobavljaci}}</span>
|
||||
</div>
|
||||
|
||||
{{if .Dobavljaci}}
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px;">
|
||||
{{range .Dobavljaci}}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;">
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{.Naziv}}</div>
|
||||
{{if or .Telefon .KontaktOsoba}}
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">
|
||||
{{if .KontaktOsoba}}{{.KontaktOsoba}}{{end}}{{if and .KontaktOsoba .Telefon}} · {{end}}{{if .Telefon}}{{telefon .Telefon}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if index $.Dozvole "artikal.izmeni"}}
|
||||
<form method="POST" action="/magacin/kartica/{{$.Artikal.ID}}/dobavljac/obrisi" style="margin:0;">
|
||||
<input type="hidden" name="dobavljac_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-obrisi-malo" data-potvrda="Ukloniti dobavljača {{.Naziv}} sa ovog artikla?">Ukloni</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="font-size:13px;color:var(--tekst-sporedni);margin-bottom:16px;">Nijedan dobavljač nije vezan za ovaj artikal.</div>
|
||||
{{end}}
|
||||
|
||||
{{if index .Dozvole "artikal.izmeni"}}
|
||||
{{if .DostupniDobavljaci}}
|
||||
<form method="POST" action="/magacin/kartica/{{.Artikal.ID}}/dobavljac/dodaj" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<select name="dobavljac_id" required style="flex:1;min-width:200px;">
|
||||
<option value="">— izaberi dobavljača —</option>
|
||||
{{range .DostupniDobavljaci}}
|
||||
<option value="{{.ID}}">{{.Naziv}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn-primarno">+ Dodaj dobavljača</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<div style="font-size:12px;color:var(--tekst-slabi);">Svi dobavljači su već vezani za ovaj artikal.</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- tabela promena -->
|
||||
<div class="kartica animiraj">
|
||||
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-bottom:14px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);">
|
||||
Prometna kartica
|
||||
<span style="font-size:12px;font-weight:400;color:var(--tekst-slabi);margin-left:8px;">{{len .Promene}} zapisa</span>
|
||||
</div>
|
||||
|
||||
{{if not .Promene}}
|
||||
<div style="text-align:center;padding:32px 0;color:var(--tekst-slabi);font-size:14px;">
|
||||
Nema evidentiranih promena za ovaj artikal.
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Vrsta promene</th>
|
||||
<th style="text-align:right;">Promena</th>
|
||||
<th style="text-align:right;">Pre</th>
|
||||
<th style="text-align:right;">Posle</th>
|
||||
<th>Napomena</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Promene}}
|
||||
<tr>
|
||||
<td style="white-space:nowrap;color:var(--tekst-slabi);font-size:12px;">
|
||||
{{.Datum.Format "02.01.2006. 15:04"}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .TipPromene "ulaz_nabavka"}}
|
||||
<span class="bedz" style="background:rgba(34,197,94,0.12);color:#22c55e;">Ulaz — nabavka</span>
|
||||
{{else if eq .TipPromene "izlaz_prodaja"}}
|
||||
<span class="bedz" style="background:rgba(59,130,246,0.12);color:#3b82f6;">Izlaz — prodaja</span>
|
||||
{{else if eq .TipPromene "izlaz_servis"}}
|
||||
<span class="bedz" style="background:rgba(249,115,22,0.12);color:#f97316;">Izlaz — servis</span>
|
||||
{{else if eq .TipPromene "povracaj"}}
|
||||
<span class="bedz" style="background:rgba(168,85,247,0.12);color:#a855f7;">Povraćaj</span>
|
||||
{{else if eq .TipPromene "korekcija"}}
|
||||
<span class="bedz" style="background:rgba(156,163,175,0.12);color:#9ca3af;">Korekcija</span>
|
||||
{{else}}
|
||||
<span class="bedz">{{.TipPromene}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td style="text-align:right;font-weight:600;font-family:monospace;{{if gt .PromenaKolicine 0}}color:#22c55e;{{else}}color:#dc2626;{{end}}">
|
||||
{{if gt .PromenaKolicine 0}}+{{end}}{{.PromenaKolicine}}
|
||||
</td>
|
||||
<td style="text-align:right;font-family:monospace;color:var(--tekst-slabi);">{{.StanjePre}}</td>
|
||||
<td style="text-align:right;font-family:monospace;font-weight:500;">{{.StanjePosle}}</td>
|
||||
<td style="font-size:12px;color:var(--tekst-slabi);">{{.Napomena}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -43,7 +43,7 @@
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ukupan iznos</div>
|
||||
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">
|
||||
{{printf "%.2f" .Nabavka.Ukupno}} din
|
||||
{{dinari .Nabavka.Ukupno}} din
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,8 +71,8 @@
|
||||
<tr class="animiraj red-tabele">
|
||||
<td style="padding:10px 16px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
|
||||
<td style="padding:10px 16px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
|
||||
<td style="padding:10px 16px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .CenaPoKomadu}} din</td>
|
||||
<td style="padding:10px 16px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
|
||||
<td style="padding:10px 16px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{dinari .CenaPoKomadu}} din</td>
|
||||
<td style="padding:10px 16px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
@@ -86,7 +86,7 @@
|
||||
<tfoot>
|
||||
<tr style="border-top:0.5px solid var(--ivica);">
|
||||
<td colspan="3" style="padding:10px 16px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-sporedni);">Ukupno:</td>
|
||||
<td style="padding:10px 16px;text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);">{{printf "%.2f" .Nabavka.Ukupno}} din</td>
|
||||
<td style="padding:10px 16px;text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);">{{dinari .Nabavka.Ukupno}} din</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{{end}}
|
||||
@@ -105,18 +105,18 @@
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--tekst-sporedni);">Cena/kom</div>
|
||||
<div style="font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .CenaPoKomadu}} din</div>
|
||||
<div style="font-size:14px;color:var(--tekst-sporedni);">{{dinari .CenaPoKomadu}} din</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:11px;color:var(--tekst-sporedni);">Ukupno</div>
|
||||
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</div>
|
||||
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Stavke}}
|
||||
<div style="text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);padding:8px 4px;">
|
||||
Ukupno: {{printf "%.2f" .Nabavka.Ukupno}} din
|
||||
Ukupno: {{dinari .Nabavka.Ukupno}} din
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -135,12 +135,12 @@
|
||||
{{range .Troskovi}}
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:0.5px solid var(--ivica);">
|
||||
<span style="font-size:14px;color:var(--tekst-glavni);">{{.Naziv}}</span>
|
||||
<span style="font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .Iznos}} din</span>
|
||||
<span style="font-size:14px;color:var(--tekst-sporedni);">{{dinari .Iznos}} din</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;">
|
||||
<span style="font-size:13px;font-weight:500;color:var(--tekst-sporedni);">Ukupno troškovi:</span>
|
||||
<span style="font-size:15px;font-weight:600;color:var(--tekst-glavni);">{{printf "%.2f" .UkupanTrosak}} din</span>
|
||||
<span style="font-size:15px;font-weight:600;color:var(--tekst-glavni);">{{dinari .UkupanTrosak}} din</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,12 +47,19 @@
|
||||
+ Novi dobavljač
|
||||
</button>
|
||||
</div>
|
||||
<select name="dobavljac_id" x-ref="selDobavljac" style="width:100%;">
|
||||
<select name="dobavljac_id" x-ref="selDobavljac" x-model="dobavljacId" style="width:100%;">
|
||||
<option value="">— bez dobavljača —</option>
|
||||
{{range .Dobavljaci}}
|
||||
<option value="{{.ID}}">{{.Naziv}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<label x-show="dobavljacId" style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;margin-top:8px;color:var(--tekst-sporedni);">
|
||||
<input type="checkbox" x-model="prikaziSveArtikle">
|
||||
Prikaži sve artikle (ne samo dobavljačeve)
|
||||
</label>
|
||||
<div x-show="dobavljacId && prikaziSveArtikle" class="pomocni-tekst" style="margin-top:4px;">
|
||||
Napomena: izabrani artikal koji nije od ovog dobavljača biće mu dodat pri čuvanju nabavke.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Napomena</label>
|
||||
@@ -73,9 +80,6 @@
|
||||
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
|
||||
+ Novi artikal
|
||||
</button>
|
||||
<button type="button" @click="dodajStavku()" class="btn-primarno" style="font-size:13px;padding:6px 14px;">
|
||||
+ Dodaj stavku
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +104,7 @@
|
||||
<select :name="'artikal_id[]'" x-model="stavka.artikal_id"
|
||||
@change="izaberiArtikal(stavka)" :disabled="isMobile" style="width:100%;">
|
||||
<option value="">— odaberi artikal —</option>
|
||||
<template x-for="a in artikliOpcije" :key="a.id">
|
||||
<template x-for="a in artikliZaDobavljaca()" :key="a.id">
|
||||
<option :value="a.id" x-text="a.naziv"></option>
|
||||
</template>
|
||||
</select>
|
||||
@@ -126,11 +130,10 @@
|
||||
min="0" step="0.01" :disabled="isMobile" style="width:100%;text-align:right;">
|
||||
</td>
|
||||
<td style="padding:8px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
|
||||
<span x-text="ukupnoStavke(stavka) + ' din'"></span>
|
||||
<span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
|
||||
</td>
|
||||
<td style="padding:8px 10px;text-align:center;">
|
||||
<button type="button" @click="ukloniStavku(i)"
|
||||
x-show="stavke.length > 1"
|
||||
style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:18px;line-height:1;padding:2px 6px;border-radius:4px;transition:background 0.15s;"
|
||||
onmouseover="this.style.background='rgba(220,38,38,0.08)'"
|
||||
onmouseout="this.style.background='none'"
|
||||
@@ -143,13 +146,17 @@
|
||||
<tr style="border-top:0.5px solid var(--ivica);">
|
||||
<td colspan="5" style="padding:10px 10px;text-align:right;font-size:13px;color:var(--tekst-sporedni);font-weight:500;">Ukupno:</td>
|
||||
<td style="padding:10px 10px;text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);">
|
||||
<span x-text="ukupnoSvega() + ' din'"></span>
|
||||
<span x-text="fmtDin(ukupnoSvega()) + ' din'"></span>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" x-show="!isMobile" @click="dodajStavku()" class="btn-primarno"
|
||||
style="width:100%;font-size:14px;padding:10px;margin-top:10px;">
|
||||
+ Dodaj stavku
|
||||
</button>
|
||||
|
||||
<!-- mobilne kartice stavki (display kontroliše .stavke-kartice: none na desktopu,
|
||||
flex na mobilnom @media — inline display:none bi pobedio @media, zato ga NEMA) -->
|
||||
@@ -160,7 +167,6 @@
|
||||
<span style="font-size:13px;font-weight:500;color:var(--tekst-sporedni);"
|
||||
x-text="'Stavka ' + (i + 1)"></span>
|
||||
<button type="button" @click="ukloniStavku(i)"
|
||||
x-show="stavke.length > 1"
|
||||
style="background:none;border:0.5px solid #dc2626;color:#dc2626;cursor:pointer;font-size:13px;padding:2px 8px;border-radius:4px;">
|
||||
Ukloni
|
||||
</button>
|
||||
@@ -171,7 +177,7 @@
|
||||
<select :name="'artikal_id[]'" x-model="stavka.artikal_id"
|
||||
@change="izaberiArtikal(stavka)" :disabled="!isMobile" style="width:100%;">
|
||||
<option value="">— odaberi artikal —</option>
|
||||
<template x-for="a in artikliOpcije" :key="a.id">
|
||||
<template x-for="a in artikliZaDobavljaca()" :key="a.id">
|
||||
<option :value="a.id" x-text="a.naziv"></option>
|
||||
</template>
|
||||
</select>
|
||||
@@ -197,13 +203,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
|
||||
Ukupno: <span x-text="ukupnoStavke(stavka) + ' din'"></span>
|
||||
Ukupno: <span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" x-show="isMobile" @click="dodajStavku()" class="btn-primarno"
|
||||
style="width:100%;font-size:14px;padding:10px;margin-top:4px;">
|
||||
+ Dodaj stavku
|
||||
</button>
|
||||
<div style="text-align:right;font-size:15px;font-weight:600;color:var(--tekst-glavni);padding:8px 4px;">
|
||||
Ukupno: <span x-text="ukupnoSvega() + ' din'"></span>
|
||||
Ukupno: <span x-text="fmtDin(ukupnoSvega()) + ' din'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,7 +251,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<div style="text-align:right;font-size:13px;color:var(--tekst-sporedni);">
|
||||
Ukupno troškovi: <strong x-text="ukupanTrosak().toFixed(2) + ' din'"></strong>
|
||||
Ukupno troškovi: <strong x-text="fmtDin(ukupanTrosak()) + ' din'"></strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -317,11 +327,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="polje-labela">Prodajna cena (din)</label>
|
||||
<input type="number" x-model="modalCena" min="0" step="0.01"
|
||||
placeholder="0"
|
||||
style="width:100%;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<label class="polje-labela">Nabavna cena (din)</label>
|
||||
<input type="number" x-model="modalNabavnaCena" min="0" step="0.01"
|
||||
placeholder="0" style="width:100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Prodajna cena (din)</label>
|
||||
<input type="number" x-model="modalCena" min="0" step="0.01"
|
||||
placeholder="0" style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{{if .Napomena}}{{.Napomena}}{{else}}—{{end}}
|
||||
</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{printf "%.2f" .Ukupno}} din
|
||||
{{dinari .Ukupno}} din
|
||||
</td>
|
||||
<td style="padding:12px 16px;text-align:center;">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;">
|
||||
@@ -87,7 +87,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);white-space:nowrap;">
|
||||
{{printf "%.2f" .Ukupno}} din
|
||||
{{dinari .Ukupno}} din
|
||||
</div>
|
||||
</div>
|
||||
{{if .Napomena}}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- istorija nivelacija -->
|
||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:760px;">
|
||||
<table class="tabela" style="width:100%;border-collapse:collapse;font-size:13px;min-width:760px;">
|
||||
<thead>
|
||||
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:10px 12px;">Datum</th>
|
||||
@@ -42,10 +42,10 @@
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:10px 12px;white-space:nowrap;">{{.Datum.Format "02.01.2006."}}</td>
|
||||
<td style="padding:10px 12px;">{{.ArtikalNaziv}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{printf "%.2f" .StaraCena}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{printf "%.2f" .NovaCena}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{dinari .StaraCena}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{dinari .NovaCena}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;white-space:nowrap;color:{{if .Poskupljenje}}var(--greska){{else}}var(--uspeh){{end}};">
|
||||
{{if .Poskupljenje}}+{{end}}{{printf "%.2f" .Razlika}}
|
||||
{{if .Poskupljenje}}+{{end}}{{dinari .Razlika}}
|
||||
<div style="font-size:11px;">{{if .Poskupljenje}}+{{end}}{{printf "%.1f" .Procenat}}%</div>
|
||||
</td>
|
||||
<td style="padding:10px 12px;">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- knjiga -->
|
||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:900px;">
|
||||
<table class="tabela" style="width:100%;border-collapse:collapse;font-size:13px;min-width:900px;">
|
||||
<thead>
|
||||
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:10px 12px;">Datum prometa</th>
|
||||
@@ -48,12 +48,12 @@
|
||||
<td style="padding:10px 12px;white-space:nowrap;">{{.DatumPrometa.Format "02.01.2006."}}</td>
|
||||
<td style="padding:10px 12px;">{{.BrojDokumenta}}</td>
|
||||
<td style="padding:10px 12px;">{{.KupacNaziv}}{{if .KupacPib}}<div style="font-size:11px;color:var(--tekst-sporedni);">{{.OznakaPoreskogBroja}}: {{.KupacPib}}</div>{{end}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OslobodenUkupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{printf "%.2f" .Ukupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .OslobodenUkupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{dinari .Ukupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;white-space:nowrap;">
|
||||
{{if eq .Izvor "rucno"}}
|
||||
<form method="POST" action="/pdv/kir/obrisi/{{.ID}}" style="display:inline;">
|
||||
@@ -72,12 +72,12 @@
|
||||
<tfoot>
|
||||
<tr style="border-top:0.5px solid var(--ivica);font-weight:500;background:var(--pozadina);">
|
||||
<td style="padding:10px 12px;" colspan="3">UKUPNO ({{len .Zapisi}})</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OslobodenUkupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.Ukupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OslobodenUkupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.Ukupno}}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- knjiga -->
|
||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:980px;">
|
||||
<table class="tabela" style="width:100%;border-collapse:collapse;font-size:13px;min-width:980px;">
|
||||
<thead>
|
||||
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:10px 12px;">Datum prometa</th>
|
||||
@@ -49,13 +49,13 @@
|
||||
<td style="padding:10px 12px;white-space:nowrap;">{{.DatumPrometa.Format "02.01.2006."}}</td>
|
||||
<td style="padding:10px 12px;">{{.BrojDokumenta}}{{if .Uvoz}}<div style="display:inline-block;margin-top:2px;font-size:10px;font-weight:600;color:var(--sb-akcent);border:0.5px solid var(--sb-akcent);border-radius:4px;padding:0 5px;">UVOZ</div>{{end}}</td>
|
||||
<td style="padding:10px 12px;">{{.DobavljacNaziv}}{{if .DobavljacPib}}<div style="font-size:11px;color:var(--tekst-sporedni);">{{.OznakaPoreskogBroja}}: {{.DobavljacPib}}</div>{{end}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .PdvBezOdbitka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .OslobodenNabavka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{printf "%.2f" .Ukupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .PdvBezOdbitka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .OslobodenNabavka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;font-weight:500;">{{dinari .Ukupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;white-space:nowrap;">
|
||||
{{if eq .Izvor "rucno"}}
|
||||
<form method="POST" action="/pdv/kpr/obrisi/{{.ID}}" style="display:inline;">
|
||||
@@ -74,13 +74,13 @@
|
||||
<tfoot>
|
||||
<tr style="border-top:0.5px solid var(--ivica);font-weight:500;background:var(--pozadina);">
|
||||
<td style="padding:10px 12px;" colspan="3">UKUPNO ({{len .Zapisi}})</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.PdvBezOdbitka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.OslobodenNabavka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Sume.Ukupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.PdvBezOdbitka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.OslobodenNabavka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Sume.Ukupno}}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<!-- obračun po stopama -->
|
||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;margin-bottom:16px;">
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:560px;">
|
||||
<table class="tabela" style="width:100%;border-collapse:collapse;font-size:13px;min-width:560px;">
|
||||
<thead>
|
||||
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:10px 12px;"></th>
|
||||
@@ -38,23 +38,23 @@
|
||||
</tr>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:10px 12px;padding-left:24px;">Opšta stopa (20%)</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .KirSume.OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.IzlazniPdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .KirSume.OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.IzlazniPdvOpsta}}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:10px 12px;padding-left:24px;">Posebna stopa (10%)</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .KirSume.OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.IzlazniPdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .KirSume.OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.IzlazniPdvPosebna}}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:10px 12px;padding-left:24px;color:var(--tekst-sporedni);">Oslobođen promet</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{printf "%.2f" .KirSume.OslobodenUkupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{dinari .KirSume.OslobodenUkupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">—</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);font-weight:500;background:var(--pozadina);">
|
||||
<td style="padding:10px 12px;">Ukupno izlazni PDV</td>
|
||||
<td style="padding:10px 12px;"></td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.IzlazniPdvUkupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.IzlazniPdvUkupno}}</td>
|
||||
</tr>
|
||||
|
||||
<!-- odbitni (prethodni) PDV — iz KPR -->
|
||||
@@ -63,23 +63,23 @@
|
||||
</tr>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:10px 12px;padding-left:24px;">Opšta stopa (20%)</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .KprSume.OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.OdbitniPdvOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .KprSume.OsnovicaOpsta}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.OdbitniPdvOpsta}}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:10px 12px;padding-left:24px;">Posebna stopa (10%)</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .KprSume.OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.OdbitniPdvPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .KprSume.OsnovicaPosebna}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.OdbitniPdvPosebna}}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:10px 12px;padding-left:24px;color:var(--tekst-sporedni);">PDV bez prava na odbitak</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">—</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{printf "%.2f" .KprSume.PdvBezOdbitka}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:var(--tekst-sporedni);">{{dinari .KprSume.PdvBezOdbitka}}</td>
|
||||
</tr>
|
||||
<tr style="font-weight:500;background:var(--pozadina);">
|
||||
<td style="padding:10px 12px;">Ukupno odbitni PDV</td>
|
||||
<td style="padding:10px 12px;"></td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{printf "%.2f" .Obracun.OdbitniPdvUkupno}}</td>
|
||||
<td style="padding:10px 12px;text-align:right;">{{dinari .Obracun.OdbitniPdvUkupno}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -96,7 +96,7 @@
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-top:2px;">izlazni − odbitni PDV</div>
|
||||
</div>
|
||||
<div style="font-size:24px;font-weight:600;color:{{if .Obracun.ZaUplatu}}var(--greska){{else}}var(--uspeh){{end}};white-space:nowrap;">
|
||||
{{printf "%.2f" .Obracun.ObavezaApsolutna}} RSD
|
||||
{{dinari .Obracun.ObavezaApsolutna}} RSD
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;margin-top:12px;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:560px;">
|
||||
<table class="tabela" style="width:100%;border-collapse:collapse;font-size:13px;min-width:560px;">
|
||||
<thead>
|
||||
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:10px 12px;width:48px;">Polje</th>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "naslov"}}Podešavanja — Servis — NTech{{end}}
|
||||
|
||||
{{define "sadrzaj"}}
|
||||
<div class="stranica-stack" style="width:100%;max-width:100%;">
|
||||
|
||||
{{if .Sacuvano}}
|
||||
<div class="poruka-uspeh">Podešavanja su uspešno sačuvana.</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/podesavanja/sacuvaj">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<input type="hidden" name="_next" value="/admin/podesavanja/servis">
|
||||
|
||||
<div class="kartica animiraj">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Servis</span>
|
||||
</div>
|
||||
|
||||
<div class="kolona" style="gap:16px;">
|
||||
|
||||
<div style="max-width:280px;">
|
||||
<label for="servis_garancija_meseci" class="polje-labela">
|
||||
Podrazumevani rok garancije (meseci)
|
||||
</label>
|
||||
<input type="number" id="servis_garancija_meseci" name="servis_garancija_meseci"
|
||||
min="0" max="120" value="{{.ServisGarancijaMeseci}}"
|
||||
style="width:100%;">
|
||||
<div class="pomocni-tekst" style="margin-top:6px;">
|
||||
Automatski se upisuje pri prijemu uređaja. Vrednost 0 znači bez garancije.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="max-width:280px;">
|
||||
<label for="predracun_rok_dana" class="polje-labela">
|
||||
Rok važenja predračuna (dana)
|
||||
</label>
|
||||
<input type="number" id="predracun_rok_dana" name="predracun_rok_dana"
|
||||
min="1" max="90" value="{{.PredracunRokDana}}"
|
||||
style="width:100%;">
|
||||
<div class="pomocni-tekst" style="margin-top:6px;">
|
||||
Koliko dana važi predračun od dana izdavanja. Štampa se na dokumentu kao „Važi do".
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:20px;">
|
||||
<button type="submit" class="btn-primarno">Sačuvaj</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,153 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "naslov"}}Popis — NTech{{end}}
|
||||
|
||||
{{define "dodatni-css"}}
|
||||
<style>
|
||||
.popis-input {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.popis-input.izmenjeno {
|
||||
border-color: #f97316;
|
||||
background: rgba(249,115,22,0.06);
|
||||
}
|
||||
.popis-razlika {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
.red-kriticno td { background: rgba(220,38,38,0.04); }
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "sadrzaj"}}
|
||||
<div class="kolona" style="gap:16px;">
|
||||
|
||||
{{if .Sacuvano}}
|
||||
<div class="poruka-uspeh poruka-animacija">Popis je uspešno sačuvan. Korekcije su upisane u magacinsku evidenciju.</div>
|
||||
{{end}}
|
||||
{{if .Greska}}
|
||||
<div class="poruka-greska greska-animacija">{{.Greska}}</div>
|
||||
{{end}}
|
||||
|
||||
<!-- uputstvo -->
|
||||
<div class="kartica animiraj" style="padding:14px 18px;">
|
||||
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:260px;">
|
||||
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-bottom:4px;">Godišnji popis magacina</div>
|
||||
<div style="font-size:13px;color:var(--tekst-slabi);line-height:1.6;">
|
||||
Unesite stvarno prebrojano stanje za svaki artikal. Polja sa razlikom biće istaknuta narandžasto.
|
||||
Samo artikli sa izmenjenom količinom biće korigovani — ostali ostaju nepromenjeni.
|
||||
</div>
|
||||
</div>
|
||||
<a href="/izvestaji" class="btn-sekundarno" style="white-space:nowrap;">← Izveštaji</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- forma -->
|
||||
<form method="POST" action="/izvestaji/popis">
|
||||
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
|
||||
|
||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||
<div style="padding:12px 20px;border-bottom:0.5px solid var(--ivica);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Artikli</span>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||
<div>
|
||||
<label class="polje-labela" style="display:inline;margin-right:6px;">Napomena:</label>
|
||||
<input type="text" name="napomena" value="Godišnji popis" style="width:200px;font-size:13px;padding:5px 8px;">
|
||||
</div>
|
||||
<button type="submit" class="btn-primarno">Sačuvaj popis</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if not .Artikli}}
|
||||
<div style="padding:40px;text-align:center;color:var(--tekst-slabi);font-size:14px;">
|
||||
Nema artikala u magacinu.
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikal</th>
|
||||
<th>Šifra</th>
|
||||
<th>Kategorija</th>
|
||||
<th style="text-align:right;">Knjižno stanje</th>
|
||||
<th style="text-align:right;">Stvarno stanje</th>
|
||||
<th style="text-align:right;">Razlika</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Artikli}}
|
||||
<tr class="{{if le .Kolicina .KolicinMin}}red-kriticno{{end}}" id="red-{{.ID}}">
|
||||
<td style="font-weight:500;">{{.Naziv}}</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 .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
|
||||
<td style="text-align:right;font-family:monospace;font-weight:600;" id="knjizno-{{.ID}}">{{.Kolicina}}</td>
|
||||
<td style="text-align:right;">
|
||||
<input type="number"
|
||||
class="popis-input"
|
||||
name="kolicina_{{.ID}}"
|
||||
id="input-{{.ID}}"
|
||||
value="{{.Kolicina}}"
|
||||
min="0"
|
||||
data-knjizno="{{.Kolicina}}"
|
||||
data-id="{{.ID}}"
|
||||
oninput="azurirajRazliku(this)">
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<span class="popis-razlika" id="razlika-{{.ID}}">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- dugme na dnu -->
|
||||
<div style="padding:14px 20px;border-top:0.5px solid var(--ivica);display:flex;justify-content:flex-end;">
|
||||
<button type="submit" class="btn-primarno">Sačuvaj popis</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function azurirajRazliku(input) {
|
||||
var id = input.dataset.id;
|
||||
var knjizno = parseInt(input.dataset.knjizno, 10);
|
||||
var stvarno = parseInt(input.value, 10);
|
||||
var span = document.getElementById('razlika-' + id);
|
||||
|
||||
if (isNaN(stvarno)) {
|
||||
span.textContent = '—';
|
||||
span.style.color = '';
|
||||
input.classList.remove('izmenjeno');
|
||||
return;
|
||||
}
|
||||
|
||||
var razlika = stvarno - knjizno;
|
||||
if (razlika === 0) {
|
||||
span.textContent = '—';
|
||||
span.style.color = '';
|
||||
input.classList.remove('izmenjeno');
|
||||
} else if (razlika > 0) {
|
||||
span.textContent = '+' + razlika;
|
||||
span.style.color = '#22c55e';
|
||||
input.classList.add('izmenjeno');
|
||||
} else {
|
||||
span.textContent = razlika;
|
||||
span.style.color = '#dc2626';
|
||||
input.classList.add('izmenjeno');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Prijava — NTech</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; position: relative; }
|
||||
@@ -57,14 +58,15 @@
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<div class="polje">
|
||||
<label for="korisnicko_ime">Korisničko ime</label>
|
||||
<input type="text" id="korisnicko_ime" name="korisnicko_ime" autocomplete="username" autofocus required>
|
||||
<input type="text" id="korisnicko_ime" name="korisnicko_ime" autocomplete="username" autofocus required{{if .JelDemo}} value="Demo"{{end}}>
|
||||
</div>
|
||||
<div class="polje">
|
||||
<label for="lozinka">Lozinka</label>
|
||||
<input type="password" id="lozinka" name="lozinka" autocomplete="current-password" required>
|
||||
<input type="password" id="lozinka" name="lozinka" autocomplete="current-password" required{{if .JelDemo}} value="Demo1234"{{end}}>
|
||||
</div>
|
||||
<button type="submit" class="dugme">Prijavi se</button>
|
||||
</form>
|
||||
<p style="text-align:center;font-size:12px;color:#4b5563;margin-top:16px;">Verzija: {{.Verzija}}</p>
|
||||
</div>
|
||||
|
||||
{{if .LoginPozadina}}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-family: monospace; color: var(--tekst-glavni)">{{.BrojNaloga}}</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.Datum.Format "02.01.2006."}}</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; font-weight: 500; color: var(--tekst-glavni)">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}}</td>
|
||||
<td style="padding: 12px 16px; text-align: right; font-size: 14px; font-weight: 500; color: var(--tekst-glavni)">{{printf "%.2f" .Ukupno}} din</td>
|
||||
<td style="padding: 12px 16px; text-align: right; font-size: 14px; font-weight: 500; color: var(--tekst-glavni)">{{dinari .Ukupno}} din</td>
|
||||
<td style="padding: 12px 16px; text-align: center">
|
||||
<div style="display: flex; align-items: center; justify-content: center; gap: 8px">
|
||||
<a href="/prodaja/{{.ID}}" class="btn-primarno-malo">Detalji</a>
|
||||
@@ -74,7 +74,7 @@
|
||||
<div style="font-size: 14px; font-weight: 500; color: var(--tekst-glavni); margin-top: 2px">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}Bez klijenta{{end}}</div>
|
||||
<div style="font-size: 12px; color: var(--tekst-sporedni); margin-top: 2px">{{.Datum.Format "02.01.2006."}}</div>
|
||||
</div>
|
||||
<div style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni); white-space: nowrap">{{printf "%.2f" .Ukupno}} din</div>
|
||||
<div style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni); white-space: nowrap">{{dinari .Ukupno}} din</div>
|
||||
</div>
|
||||
<a href="/prodaja/{{.ID}}" class="btn-primarno-malo" style="justify-content: center; width: 100%; box-sizing: border-box">Detalji</a>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ukupno</div>
|
||||
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">
|
||||
{{printf "%.2f" .Nalog.Ukupno}} din
|
||||
{{dinari .Nalog.Ukupno}} din
|
||||
</div>
|
||||
</div>
|
||||
{{if .Nalog.RazlogStorniranja}}
|
||||
@@ -205,18 +205,18 @@
|
||||
<tr style="border-bottom: 0.5px solid var(--ivica)">
|
||||
<td style="padding:10px 20px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
|
||||
<td style="padding:10px 20px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
|
||||
<td style="padding:10px 20px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{printf "%.2f" .CenaPoKomadu}} din</td>
|
||||
<td style="padding:10px 20px;text-align:right;font-size:14px;color:var(--tekst-sporedni);">{{dinari .CenaPoKomadu}} din</td>
|
||||
<td style="padding:10px 20px;text-align:center;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if .PdvStopa}}{{printf "%.0f" .PdvStopa}}%{{else}}—{{end}}
|
||||
</td>
|
||||
<td style="padding:10px 20px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
|
||||
<td style="padding:10px 20px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="border-top: 0.5px solid var(--ivica)">
|
||||
<td colspan="4" style="padding:12px 20px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-sporedni);">Ukupno:</td>
|
||||
<td style="padding:12px 20px;text-align:right;font-size:16px;font-weight:600;color:var(--sb-akcent);">{{printf "%.2f" .Nalog.Ukupno}} din</td>
|
||||
<td style="padding:12px 20px;text-align:right;font-size:16px;font-weight:600;color:var(--sb-akcent);">{{dinari .Nalog.Ukupno}} din</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
font-weight: 500;
|
||||
color: var(--tekst-glavni);
|
||||
">
|
||||
<span x-text="ukupnoStavke(stavka) + ' din'"></span>
|
||||
<span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
|
||||
</td>
|
||||
<td style="padding: 8px 10px; text-align: center">
|
||||
<button
|
||||
@@ -265,7 +265,7 @@
|
||||
font-weight: 600;
|
||||
color: var(--tekst-glavni);
|
||||
">
|
||||
<span x-text="ukupnoSvega() + ' din'"></span>
|
||||
<span x-text="fmtDin(ukupnoSvega()) + ' din'"></span>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
@@ -390,7 +390,7 @@
|
||||
font-weight: 500;
|
||||
color: var(--tekst-glavni);
|
||||
">
|
||||
Ukupno: <span x-text="ukupnoStavke(stavka) + ' din'"></span>
|
||||
Ukupno: <span x-text="fmtDin(ukupnoStavke(stavka)) + ' din'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,7 +403,7 @@
|
||||
color: var(--tekst-glavni);
|
||||
padding: 8px 4px;
|
||||
">
|
||||
Ukupno: <span x-text="ukupnoSvega() + ' din'"></span>
|
||||
Ukupno: <span x-text="fmtDin(ukupnoSvega()) + ' din'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
{{end}} {{if .Adresa}}
|
||||
<div class="firma-kontakt">{{.Adresa}}</div>
|
||||
{{end}} {{if .Telefon}}
|
||||
<div class="firma-kontakt">{{.Telefon}}</div>
|
||||
<div class="firma-kontakt">{{telefon .Telefon}}</div>
|
||||
{{end}} {{if .PIB}}
|
||||
<div class="firma-kontakt">PIB: {{.PIB}}</div>
|
||||
{{end}}
|
||||
@@ -81,16 +81,16 @@
|
||||
{{range .Stavke}}
|
||||
<tr>
|
||||
<td>{{.ArtikalNaziv}}</td>
|
||||
<td>{{.Kolicina}}</td>
|
||||
<td>{{printf "%.2f" .CenaPoKomadu}} din</td>
|
||||
<td>{{printf "%.2f" .Ukupno}} din</td>
|
||||
<td>{{.Kolicina}} {{.JedinicaMere}}</td>
|
||||
<td>{{dinari .CenaPoKomadu}} din</td>
|
||||
<td>{{dinari .Ukupno}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" class="ukupno-label">Ukupno za naplatu:</td>
|
||||
<td class="ukupno-iznos">{{printf "%.2f" .Nalog.Ukupno}} din</td>
|
||||
<td class="ukupno-iznos">{{dinari .Nalog.Ukupno}} din</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -74,6 +74,117 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- kartica: animacije -->
|
||||
<div class="kartica animiraj" style="margin-bottom:16px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12s2.545-5 7-5c4.454 0 7 5 7 5s-2.546 5-7 5c-4.455 0-7-5-7-5z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Animacije tabela</span>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/profil/animacija">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<input type="hidden" name="lokalna_brzina_animacije" id="brzina-hidden" value="{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}{{else}}0.4{{end}}">
|
||||
<div class="kolona" style="gap:16px;">
|
||||
<div style="max-width:300px;">
|
||||
<label class="polje-labela" for="anim-select">Vrsta animacije pri učitavanju</label>
|
||||
<select id="anim-select" name="lokalna_animacija" style="width:100%;" onchange="animPreview(this.value)">
|
||||
<option value="" {{if eq .LokalnaAnimacija ""}}selected{{end}}>Podrazumevano (klizanje gore)</option>
|
||||
<option value="bez" {{if eq .LokalnaAnimacija "bez"}}selected{{end}}>Bez animacije</option>
|
||||
<option value="fadeInUp" {{if eq .LokalnaAnimacija "fadeInUp"}}selected{{end}}>Klizanje gore</option>
|
||||
<option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje</option>
|
||||
<option value="blurIn" {{if eq .LokalnaAnimacija "blurIn"}}selected{{end}}>Zamagljivanje</option>
|
||||
<option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- preview -->
|
||||
<div>
|
||||
<div class="pomocni-tekst" style="margin-bottom:8px;">Pregled:</div>
|
||||
<div id="anim-preview-wrap" style="border:0.5px solid var(--ivica);border-radius:8px;overflow:hidden;max-width:340px;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tbody id="anim-preview-body">
|
||||
<tr class="animiraj" style="border-bottom:0.5px solid var(--ivica);"><td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">Laptop HP 840</td><td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">120.000 din</td></tr>
|
||||
<tr class="animiraj" style="border-bottom:0.5px solid var(--ivica);animation-delay:0.08s;"><td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">iPhone 14 Pro</td><td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">95.000 din</td></tr>
|
||||
<tr class="animiraj" style="animation-delay:0.16s;"><td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">Samsung S24</td><td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">80.000 din</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" onclick="animPreviewPonovi()"
|
||||
style="margin-top:8px;padding:6px 14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;font-size:12px;color:var(--tekst-sporedni);cursor:pointer;">
|
||||
Ponovi pregled
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- slider brzine unutar iste forme -->
|
||||
<div style="max-width:340px;">
|
||||
<label class="polje-labela" for="brzina-slider">Trajanje efekta: <span id="brzina-labela">{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}s{{else}}0.4s{{end}}</span></label>
|
||||
<input type="range" id="brzina-slider"
|
||||
min="0.1" max="0.8" step="0.1"
|
||||
value="{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}{{else}}0.4{{end}}"
|
||||
style="width:100%;margin-top:8px;accent-color:var(--sb-akcent);"
|
||||
oninput="brzinaPromena(this.value)">
|
||||
<div style="display:flex;justify-content:space-between;margin-top:4px;">
|
||||
<span style="font-size:11px;color:var(--tekst-sporedni);">0.1s (brže)</span>
|
||||
<span style="font-size:11px;color:var(--tekst-sporedni);">0.8s (sporije)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn-primarno">Sačuvaj</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- kartica: hover efekat -->
|
||||
<div class="kartica animiraj" style="margin-bottom:16px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3l14 9-7 1-4 7z"/></svg>
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Hover efekat kartica</span>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/profil/hover">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<div class="kolona" style="gap:16px;">
|
||||
<div style="max-width:300px;">
|
||||
<label class="polje-labela" for="hover-select">Efekat pri prelasku mišem</label>
|
||||
<select id="hover-select" name="lokalni_hover" style="width:100%;" onchange="hoverPreview(this.value)">
|
||||
<option value="" {{if eq .LokalniHover ""}}selected{{end}}>Podrazumevano (senka + ivica)</option>
|
||||
<option value="bez" {{if eq .LokalniHover "bez"}}selected{{end}}>Bez efekta</option>
|
||||
<option value="podizanje" {{if eq .LokalniHover "podizanje"}}selected{{end}}>Podizanje (lift)</option>
|
||||
<option value="svetlost" {{if eq .LokalniHover "svetlost"}}selected{{end}}>Svetlost ivice (akcent)</option>
|
||||
<option value="zoom" {{if eq .LokalniHover "zoom"}}selected{{end}}>Zoom (uvećanje)</option>
|
||||
<option value="boja" {{if eq .LokalniHover "boja"}}selected{{end}}>Boja pozadine (akcent)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- preview -->
|
||||
<div>
|
||||
<div class="pomocni-tekst" style="margin-bottom:8px;">Pregled — pređi mišem:</div>
|
||||
<div id="hover-preview-wrap" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;max-width:380px;">
|
||||
<div id="hprev1" class="kartica" style="padding:12px;cursor:default;min-height:64px;display:flex;flex-direction:column;gap:4px;">
|
||||
<div style="font-size:11px;color:var(--tekst-sporedni);">Artikli</div>
|
||||
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">1.284</div>
|
||||
</div>
|
||||
<div id="hprev2" class="kartica" style="padding:12px;cursor:default;min-height:64px;display:flex;flex-direction:column;gap:4px;">
|
||||
<div style="font-size:11px;color:var(--tekst-sporedni);">Servis</div>
|
||||
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">47</div>
|
||||
</div>
|
||||
<div id="hprev3" class="kartica" style="padding:12px;cursor:default;min-height:64px;display:flex;flex-direction:column;gap:4px;">
|
||||
<div style="font-size:11px;color:var(--tekst-sporedni);">Prihod</div>
|
||||
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">+12%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pomocni-tekst" style="margin-top:6px;font-size:11px;">Efekat se primenjuje odmah pri odabiru.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn-primarno">Sačuvaj</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- kartica: moj avatar -->
|
||||
<div class="kartica animiraj" style="margin-bottom:16px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||
@@ -255,4 +366,66 @@
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function hoverPreview(val) {
|
||||
var wrap = document.getElementById('hover-preview-wrap');
|
||||
if (!wrap) return;
|
||||
if (val === '') {
|
||||
wrap.removeAttribute('data-hover');
|
||||
} else {
|
||||
wrap.setAttribute('data-hover', val);
|
||||
}
|
||||
}
|
||||
// inicijalizuj preview na osnovu trenutnog odabira pri učitavanju
|
||||
(function() {
|
||||
var sel = document.getElementById('hover-select');
|
||||
if (sel) hoverPreview(sel.value);
|
||||
})();
|
||||
|
||||
// mapiranje vrednosti dropdowna na vrednost data-animacija atributa.
|
||||
// prazna vrednost ("Podrazumevano") odgovara fadeInUp — istom podrazumevanom stilu.
|
||||
var animVrednosti = {
|
||||
'': 'fadeInUp',
|
||||
'bez': 'bez',
|
||||
'fadeInUp': 'fadeInUp',
|
||||
'fadeIn': 'fadeIn',
|
||||
'blurIn': 'blurIn',
|
||||
'slideLeft': 'slideLeft'
|
||||
};
|
||||
// postavlja data-animacija na PREVIEW wrapper (ne na body), pa CSS
|
||||
// [data-animacija] .animiraj radi izolovano — samo nad redovima preview tabele.
|
||||
function animPreview(val) {
|
||||
var wrap = document.getElementById('anim-preview-wrap');
|
||||
if (!wrap) return;
|
||||
var anim = animVrednosti[val] || 'fadeInUp';
|
||||
// skini data-animacija i klasu animiraj sa redova da bi se animacija resetovala
|
||||
wrap.removeAttribute('data-animacija');
|
||||
var redovi = wrap.querySelectorAll('.animiraj');
|
||||
redovi.forEach(function(r) { r.classList.remove('animiraj'); });
|
||||
void wrap.offsetHeight; // reflow — bez ovoga browser spoji promene i ne restartuje
|
||||
wrap.setAttribute('data-animacija', anim);
|
||||
redovi.forEach(function(r) { r.classList.add('animiraj'); });
|
||||
}
|
||||
// "Ponovi pregled" — ponovo okida animaciju za trenutno izabrani stil
|
||||
function animPreviewPonovi() {
|
||||
animPreview(document.getElementById('anim-select').value);
|
||||
}
|
||||
// inicijalizuj preview na osnovu sačuvane preferencije pri učitavanju stranice
|
||||
(function() {
|
||||
var sel = document.getElementById('anim-select');
|
||||
if (sel) animPreview(sel.value);
|
||||
})();
|
||||
|
||||
function brzinaPromena(val) {
|
||||
var v = parseFloat(val).toFixed(1);
|
||||
document.getElementById('brzina-hidden').value = v;
|
||||
document.getElementById('brzina-labela').textContent = v + 's';
|
||||
document.body.dataset.brzinaAnimacije = v;
|
||||
var wrap = document.getElementById('anim-preview-wrap');
|
||||
if (wrap) wrap.style.setProperty('--anim-trajanje', v + 's');
|
||||
// ponovi preview da korisnik odmah vidi novu brzinu
|
||||
var sel = document.getElementById('anim-select');
|
||||
if (sel) animPreview(sel.value);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "naslov"}}Prometni list — NTech{{end}}
|
||||
|
||||
{{define "sadrzaj"}}
|
||||
<div class="kolona" style="gap:16px;">
|
||||
|
||||
<!-- filter perioda -->
|
||||
<div class="kartica animiraj">
|
||||
<form method="GET" action="/izvestaji/prometni-list" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||
<div>
|
||||
<label class="polje-labela">Od datuma</label>
|
||||
<input type="date" name="od" value="{{.Od}}" style="width:160px;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Do datuma</label>
|
||||
<input type="date" name="do" value="{{.Do}}" style="width:160px;">
|
||||
</div>
|
||||
<button type="submit" class="btn-primarno">Prikaži</button>
|
||||
<a href="/izvestaji" class="btn-sekundarno">← Izveštaji</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- tabela promena -->
|
||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||
<div style="padding:16px 20px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Prometni list magacina</span>
|
||||
<span style="font-size:12px;color:var(--tekst-slabi);">{{.Od}} — {{.Do}} · {{.Ukupno}} promena</span>
|
||||
</div>
|
||||
|
||||
{{if not .Promene}}
|
||||
<div style="padding:40px;text-align:center;color:var(--tekst-slabi);font-size:14px;">
|
||||
Nema promena u odabranom periodu.
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Artikal</th>
|
||||
<th>Šifra</th>
|
||||
<th>Vrsta</th>
|
||||
<th style="text-align:right;">Promena</th>
|
||||
<th style="text-align:right;">Pre</th>
|
||||
<th style="text-align:right;">Posle</th>
|
||||
<th>Napomena</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Promene}}
|
||||
<tr>
|
||||
<td style="white-space:nowrap;font-size:12px;color:var(--tekst-slabi);">
|
||||
{{.Datum.Format "02.01.2006. 15:04"}}
|
||||
</td>
|
||||
<td style="font-weight:500;">{{.ArtikalNaziv}}</td>
|
||||
<td style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .ArtikalSifra}}{{.ArtikalSifra}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if eq .TipPromene "ulaz_nabavka"}}
|
||||
<span class="bedz" style="background:rgba(34,197,94,0.12);color:#22c55e;">Ulaz</span>
|
||||
{{else if eq .TipPromene "izlaz_prodaja"}}
|
||||
<span class="bedz" style="background:rgba(59,130,246,0.12);color:#3b82f6;">Prodaja</span>
|
||||
{{else if eq .TipPromene "izlaz_servis"}}
|
||||
<span class="bedz" style="background:rgba(249,115,22,0.12);color:#f97316;">Servis</span>
|
||||
{{else if eq .TipPromene "povracaj"}}
|
||||
<span class="bedz" style="background:rgba(168,85,247,0.12);color:#a855f7;">Povraćaj</span>
|
||||
{{else if eq .TipPromene "korekcija"}}
|
||||
<span class="bedz" style="background:rgba(156,163,175,0.12);color:#9ca3af;">Korekcija</span>
|
||||
{{else}}
|
||||
<span class="bedz">{{.TipPromene}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td style="text-align:right;font-family:monospace;font-weight:600;{{if gt .PromenaKolicine 0}}color:#22c55e;{{else}}color:#dc2626;{{end}}">
|
||||
{{if gt .PromenaKolicine 0}}+{{end}}{{.PromenaKolicine}}
|
||||
</td>
|
||||
<td style="text-align:right;font-family:monospace;color:var(--tekst-slabi);">{{.StanjePre}}</td>
|
||||
<td style="text-align:right;font-family:monospace;font-weight:500;">{{.StanjePosle}}</td>
|
||||
<td style="font-size:12px;color:var(--tekst-slabi);">{{.Napomena}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -94,7 +94,7 @@
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="6" style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
|
||||
Nema servisnih naloga. <a href="/servis/novi" style="color:var(--sb-akcent);">Dodaj prvi nalog.</a>
|
||||
{{if or $.Pretraga $.FilterStatus}}Za datu pretragu nema servisnih naloga u bazi.{{else}}Nema servisnih naloga. <a href="/servis/novi" style="color:var(--sb-akcent);">Dodaj prvi nalog.</a>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -145,7 +145,7 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="padding:32px;text-align:center;font-size:14px;color:var(--tekst-sporedni);">
|
||||
Nema servisnih naloga. <a href="/servis/novi" style="color:var(--sb-akcent);">Dodaj prvi nalog.</a>
|
||||
{{if or $.Pretraga $.FilterStatus}}Za datu pretragu nema servisnih naloga u bazi.{{else}}Nema servisnih naloga. <a href="/servis/novi" style="color:var(--sb-akcent);">Dodaj prvi nalog.</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -43,11 +43,37 @@
|
||||
<span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;">
|
||||
{{.Nalog.BrojNaloga}}
|
||||
</span>
|
||||
{{template "status-badge-detalji" .Nalog.Status}}
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
{{template "status-badge-detalji" .Nalog.Status}}
|
||||
<form method="post" action="/servis/{{.Nalog.ID}}/status" style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<select name="status" style="font-size:13px;padding:4px 8px;border-radius:6px;border:0.5px solid var(--ivica);background:var(--kartica-pozadina);color:var(--tekst-glavni);cursor:pointer;">
|
||||
{{range .SviStatusi}}
|
||||
<option value="{{.}}"{{if eq . $.Nalog.Status}} selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:4px 12px;">Promeni</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
{{if not (or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto"))}}
|
||||
<a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno">
|
||||
Radni nalog
|
||||
</a>
|
||||
<a href="/servis/{{.Nalog.ID}}/predracun" target="_blank" class="btn-sekundarno">
|
||||
Predračun
|
||||
</a>
|
||||
{{end}}
|
||||
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
|
||||
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
|
||||
Otpremnica
|
||||
</a>
|
||||
{{end}}
|
||||
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno">
|
||||
Izmeni nalog
|
||||
</a>
|
||||
</div>
|
||||
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno">
|
||||
Izmeni nalog
|
||||
</a>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(140px, 1fr));gap:16px;">
|
||||
<div>
|
||||
@@ -103,6 +129,28 @@
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:6px;">Opis kvara</div>
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);line-height:1.6;white-space:pre-wrap;">{{.Nalog.OpisKvara}}</div>
|
||||
</div>
|
||||
{{if or .Nalog.Ostecenja .Nalog.PinUredjaja .Nalog.Pribor}}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(160px, 1fr));gap:16px;">
|
||||
{{if .Nalog.Ostecenja}}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Oštećenja pri prijemu</div>
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.Ostecenja}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Nalog.PinUredjaja}}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">PIN / lozinka uređaja</div>
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);font-family:monospace;">{{.Nalog.PinUredjaja}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Nalog.Pribor}}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Pribor i oprema</div>
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);">{{.Nalog.Pribor}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Nalog.Napomena}}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:6px;">Napomena</div>
|
||||
@@ -131,22 +179,38 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Konačna cena</div>
|
||||
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Cena rada</div>
|
||||
<div style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{if .Nalog.CenaKonacna}}{{.Nalog.CenaKonacnaStr}} din{{else}}—{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if gt .UkupnoDelovi 0.0}}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ugrađeni delovi</div>
|
||||
<div style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{dinari .UkupnoDelovi}} din
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Nalog.CenaKonacna}}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Ukupno</div>
|
||||
<div style="font-size:20px;font-weight:600;color:var(--sb-akcent);">
|
||||
{{dinari .UkupnoSve}} din
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Avans</div>
|
||||
<div style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{if .Nalog.Avans}}{{.Nalog.AvansStr}} din{{else}}—{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .Nalog.PreostaloZaNaplatu}}
|
||||
{{if .Nalog.CenaKonacna}}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Preostalo za naplatu</div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Za naplatu</div>
|
||||
<div style="font-size:20px;font-weight:600;color:#16a34a;">
|
||||
{{.Nalog.PreostaloZaNaplatuStr}} din
|
||||
{{dinari .PreostaloSve}} din
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -169,7 +233,7 @@
|
||||
<select name="artikal_id" style="width:100%;" required>
|
||||
<option value="">— odaberi —</option>
|
||||
{{range .Artikli}}
|
||||
<option value="{{.ID}}">{{.Naziv}} ({{.Kolicina}} kom)</option>
|
||||
<option value="{{.ID}}" data-cena="{{.ProdajnaCena}}">{{.Naziv}} ({{.Kolicina}} kom)</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
@@ -205,8 +269,8 @@
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:9px 10px;font-size:14px;color:var(--tekst-glavni);">{{.ArtikalNaziv}}</td>
|
||||
<td style="padding:9px 10px;text-align:center;font-size:14px;color:var(--tekst-glavni);">{{.Kolicina}}</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{printf "%.2f" .CenaKomada}} din</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.2f" .Ukupno}} din</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;color:var(--tekst-glavni);">{{dinari .CenaKomada}} din</td>
|
||||
<td style="padding:9px 10px;text-align:right;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .Ukupno}} din</td>
|
||||
<td style="padding:9px 10px;text-align:center;">
|
||||
{{if index $.Dozvole "servis.izmeni"}}
|
||||
<form method="POST" action="/servis/{{$.Nalog.ID}}/delovi/{{.ID}}/obrisi" style="display:inline;">
|
||||
@@ -249,4 +313,21 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var sel = document.querySelector('select[name="artikal_id"]');
|
||||
var cenaInput = document.querySelector('input[name="cena_komada"]');
|
||||
if (!sel || !cenaInput) return;
|
||||
sel.addEventListener('change', function() {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
var cena = parseFloat(opt.dataset.cena);
|
||||
if (!isNaN(cena) && cena > 0) {
|
||||
cenaInput.value = cena.toFixed(2);
|
||||
} else {
|
||||
cenaInput.value = '0';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
</a>
|
||||
|
||||
<div class="kartica forma-kartica animiraj">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:14px;border-bottom:0.5px solid var(--ivica);flex-wrap:wrap;gap:10px;">
|
||||
<span style="font-size:16px;font-weight:500;color:var(--tekst-glavni);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin:-16px -16px 20px -16px;padding:12px 16px;background:var(--pozadina);border-radius:12px 12px 0 0;flex-wrap:wrap;gap:10px;">
|
||||
<span style="font-size:15px;font-weight:600;color:var(--tekst-glavni);">
|
||||
{{if .Izmena}}Izmeni nalog{{else}}Novi nalog{{end}}
|
||||
</span>
|
||||
<span style="font-size:14px;font-weight:500;color:var(--tekst-sporedni);font-family:monospace;">
|
||||
<span style="font-size:13px;font-weight:500;color:var(--tekst-sporedni);font-family:monospace;">
|
||||
{{.Nalog.BrojNaloga}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -60,6 +60,26 @@
|
||||
placeholder="Opišite problem koji je klijent prijavio..."
|
||||
style="width:100%;resize:vertical;">{{.Nalog.OpisKvara}}</textarea>
|
||||
</div>
|
||||
<div class="forma-grid-2" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<label class="polje-labela">Oštećenja pri prijemu</label>
|
||||
<input type="text" name="ostecenja" value="{{.Nalog.Ostecenja}}"
|
||||
placeholder="npr. ogrebotina na ekranu"
|
||||
style="width:100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">PIN / lozinka uređaja</label>
|
||||
<input type="text" name="pin_uredjaja" value="{{.Nalog.PinUredjaja}}"
|
||||
placeholder="npr. 1234"
|
||||
style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Pribor i oprema</label>
|
||||
<input type="text" name="pribor" value="{{.Nalog.Pribor}}"
|
||||
placeholder="npr. punjač, torbica, miš"
|
||||
style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,8 +117,8 @@
|
||||
<!-- status i datumi -->
|
||||
<div>
|
||||
<div class="sekcija-naslov">Status i datumi</div>
|
||||
<div class="forma-grid-4" style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;">
|
||||
<div style="grid-column:span 2;">
|
||||
<div class="kolona" style="gap:12px;">
|
||||
<div>
|
||||
<label class="polje-labela">Status naloga</label>
|
||||
<select name="status" style="width:100%;">
|
||||
{{range .SviStatusi}}
|
||||
@@ -106,17 +126,38 @@
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Datum završetka</label>
|
||||
<input type="date" name="datum_zavrsetka"
|
||||
value="{{if .Nalog.DatumZavrsetka}}{{.Nalog.DatumZavrsetka.Format "2006-01-02"}}{{end}}"
|
||||
style="width:100%;">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;">
|
||||
<div>
|
||||
<label class="polje-labela">Datum prijema</label>
|
||||
<input type="date" name="datum_prijema"
|
||||
value="{{.Nalog.DatumPrijema.Format "2006-01-02"}}"
|
||||
style="width:100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Datum završetka</label>
|
||||
<input type="date" name="datum_zavrsetka"
|
||||
value="{{if .Nalog.DatumZavrsetka}}{{.Nalog.DatumZavrsetka.Format "2006-01-02"}}{{end}}"
|
||||
style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Garancija do</label>
|
||||
<input type="date" name="garancija_do"
|
||||
<input type="date" name="garancija_do" id="garancija_do_input"
|
||||
value="{{if .Nalog.GarancijaDo}}{{.Nalog.GarancijaDo.Format "2006-01-02"}}{{end}}"
|
||||
style="width:100%;">
|
||||
{{if not .Nalog.GarancijaDo}}disabled{{end}}
|
||||
style="width:100%;{{if not .Nalog.GarancijaDo}}opacity:0.4;{{end}}">
|
||||
</div>
|
||||
<!-- svič bez garancije -->
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<label class="toggl" style="flex-shrink:0;">
|
||||
<input type="checkbox" id="bez_garancije_chk"
|
||||
{{if not .Nalog.GarancijaDo}}checked{{end}}
|
||||
onchange="toggleGarancija(this)">
|
||||
<span class="toggl-klizac"></span>
|
||||
</label>
|
||||
<input type="hidden" name="bez_garancije" id="bez_garancije_val"
|
||||
value="{{if not .Nalog.GarancijaDo}}1{{else}}0{{end}}">
|
||||
<span style="font-size:13px;color:var(--tekst-sporedni);">Bez garancije</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +179,7 @@
|
||||
placeholder="0" style="width:100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Konačna cena</label>
|
||||
<label class="polje-labela">Cena rada</label>
|
||||
<input type="number" name="cena_konacna" min="0" step="0.01"
|
||||
value="{{if .Nalog.CenaKonacna}}{{.Nalog.CenaKonacna}}{{end}}"
|
||||
placeholder="0" style="width:100%;">
|
||||
@@ -172,4 +213,22 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleGarancija(chk) {
|
||||
var input = document.getElementById('garancija_do_input');
|
||||
var hidden = document.getElementById('bez_garancije_val');
|
||||
if (chk.checked) {
|
||||
if (input.value) input.dataset.sacuvano = input.value;
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
input.style.opacity = '0.4';
|
||||
hidden.value = '1';
|
||||
} else {
|
||||
input.disabled = false;
|
||||
input.style.opacity = '1';
|
||||
if (input.dataset.sacuvano) input.value = input.dataset.sacuvano;
|
||||
hidden.value = '0';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Otpremnica — {{.Nalog.BrojNaloga}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 13px; color: #111; background: #fff; }
|
||||
|
||||
.strana { max-width: 780px; margin: 0 auto; padding: 32px 40px; }
|
||||
|
||||
/* zaglavlje */
|
||||
.zaglavlje { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 2px solid #111; gap: 20px; }
|
||||
.firma-naziv { font-size: 20px; font-weight: 700; }
|
||||
.firma-info { font-size: 11px; color: #555; margin-top: 4px; line-height: 1.7; }
|
||||
.dok-naslov { text-align: right; }
|
||||
.dok-tip { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #555; margin-bottom: 4px; }
|
||||
.dok-broj { font-size: 22px; font-weight: 700; font-family: monospace; }
|
||||
.dok-datum { font-size: 12px; color: #555; margin-top: 6px; }
|
||||
|
||||
/* strane */
|
||||
.strane-blok { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||
.strana-kartica { padding: 12px 14px; border: 0.5px solid #ddd; border-radius: 6px; }
|
||||
.strana-tip { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #888; margin-bottom: 6px; }
|
||||
.strana-naziv { font-size: 14px; font-weight: 600; }
|
||||
.strana-info { font-size: 12px; color: #555; margin-top: 3px; line-height: 1.6; }
|
||||
|
||||
/* odeljci */
|
||||
.odeljak { margin-bottom: 18px; }
|
||||
.odeljak-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: #555; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 0.5px solid #ccc; }
|
||||
|
||||
/* podaci */
|
||||
.podaci-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.podaci-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.polje-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 3px; }
|
||||
.polje-vrednost { font-size: 13px; font-weight: 500; }
|
||||
.polje-vrednost-mono { font-family: monospace; font-size: 13px; font-weight: 500; }
|
||||
.polje-tekst { font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||
.prazno { color: #aaa; font-style: italic; }
|
||||
|
||||
/* tabela delova */
|
||||
.tabela { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
||||
.tabela th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: #555; padding: 6px 8px; border-bottom: 1.5px solid #111; text-align: left; }
|
||||
.tabela th.desno { text-align: right; }
|
||||
.tabela td { padding: 7px 8px; border-bottom: 0.5px solid #ddd; font-size: 13px; vertical-align: middle; }
|
||||
.tabela td.desno { text-align: right; }
|
||||
.tabela .ukupno-red td { font-weight: 600; border-top: 1.5px solid #111; border-bottom: none; background: #fafafa; }
|
||||
|
||||
/* iznos za naplatu */
|
||||
.naplata-blok { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border: 1.5px solid #111; border-radius: 6px; margin-top: 12px; }
|
||||
.naplata-labela { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.naplata-iznos { font-size: 22px; font-weight: 700; }
|
||||
|
||||
/* garancija */
|
||||
.garancija-blok { padding: 10px 14px; border: 0.5px solid #86efac; background: #f0fdf4; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.garancija-tekst { font-size: 12px; color: #15803d; font-weight: 500; }
|
||||
.garancija-datum { font-size: 15px; font-weight: 700; color: #15803d; }
|
||||
|
||||
/* potpisi */
|
||||
.potpisi { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 36px; padding-top: 16px; border-top: 0.5px solid #ccc; }
|
||||
.potpis-linija { border-top: 1px solid #888; margin-top: 44px; padding-top: 6px; font-size: 11px; color: #555; text-align: center; }
|
||||
|
||||
/* napomena na dnu */
|
||||
.napomena-dno { margin-top: 20px; padding: 10px 14px; background: #f9fafb; border-left: 3px solid #ddd; font-size: 12px; color: #555; line-height: 1.6; }
|
||||
|
||||
@media print {
|
||||
body { font-size: 12px; }
|
||||
.strana { padding: 16px 20px; max-width: 100%; }
|
||||
.dugme-stampa { display: none !important; }
|
||||
@page { size: A4; margin: 12mm 14mm; }
|
||||
}
|
||||
.dugme-stampa { position: fixed; bottom: 24px; right: 24px; background: #111; color: #fff; border: none; padding: 12px 22px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
|
||||
.dugme-stampa:hover { background: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="strana">
|
||||
|
||||
<!-- zaglavlje -->
|
||||
<div class="zaglavlje">
|
||||
<div>
|
||||
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
|
||||
<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}}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-start;gap:14px;">
|
||||
<div class="dok-naslov">
|
||||
<div class="dok-tip">Otpremnica</div>
|
||||
<div class="dok-broj">{{.Nalog.BrojNaloga}}</div>
|
||||
<div class="dok-datum">Datum: {{.Nalog.DatumPrijema.Format "02.01.2006."}}</div>
|
||||
{{if .Nalog.DatumZavrsetka}}
|
||||
<div class="dok-datum">Završeno: {{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .QRKod}}
|
||||
<img src="data:image/png;base64,{{.QRKod}}" width="76" height="76"
|
||||
alt="QR {{.Nalog.BrojNaloga}}"
|
||||
style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- isporučilac i primalac -->
|
||||
<div class="strane-blok">
|
||||
<div class="strana-kartica">
|
||||
<div class="strana-tip">Isporučilac</div>
|
||||
<div class="strana-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}—{{end}}</div>
|
||||
<div class="strana-info">
|
||||
{{if .Adresa}}{{.Adresa}}<br>{{end}}
|
||||
{{if .Telefon}}{{telefon .Telefon}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="strana-kartica">
|
||||
<div class="strana-tip">Primalac (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}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="strana-naziv prazno">— klijent nije naveden —</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- uređaj koji se vraća -->
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Uređaj koji se preuzima</div>
|
||||
<div class="podaci-grid" style="margin-bottom:10px;">
|
||||
<div>
|
||||
<div class="polje-labela">Naziv uređaja</div>
|
||||
<div class="polje-vrednost">{{.Nalog.Uredjaj}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="polje-labela">Serijski broj</div>
|
||||
<div class="polje-vrednost-mono">{{if .Nalog.SerijskiBroj}}{{.Nalog.SerijskiBroj}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||
</div>
|
||||
{{if .TehnicarNaziv}}
|
||||
<div>
|
||||
<div class="polje-labela">Tehničar</div>
|
||||
<div class="polje-vrednost">{{.TehnicarNaziv}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Nalog.Pribor}}
|
||||
<div style="margin-top:8px;">
|
||||
<div class="polje-labela">Pribor i oprema</div>
|
||||
<div class="polje-tekst">{{.Nalog.Pribor}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- opis izvršenih radova -->
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Opis izvršenih radova</div>
|
||||
<div class="polje-tekst">{{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||
</div>
|
||||
|
||||
<!-- ugrađeni delovi -->
|
||||
{{if .ServisniDelovi}}
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Ugrađeni delovi i materijal</div>
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikal</th>
|
||||
<th class="desno" style="width:70px;">Kol.</th>
|
||||
<th class="desno" style="width:120px;">Cena/kom</th>
|
||||
<th class="desno" style="width:120px;">Ukupno</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .ServisniDelovi}}
|
||||
<tr>
|
||||
<td>{{.ArtikalNaziv}}</td>
|
||||
<td class="desno">{{.Kolicina}}</td>
|
||||
<td class="desno">{{dinari .CenaKomada}} din</td>
|
||||
<td class="desno">{{dinari .Ukupno}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr class="ukupno-red">
|
||||
<td colspan="3">Ukupno delovi</td>
|
||||
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- iznos za naplatu -->
|
||||
{{if .Nalog.CenaKonacna}}
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Obračun</div>
|
||||
<table class="tabela" style="margin-bottom:10px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cena rada</td>
|
||||
<td class="desno" style="width:160px;">{{.Nalog.CenaKonacnaStr}} din</td>
|
||||
</tr>
|
||||
{{if gt .UkupnoDelovi 0.0}}
|
||||
<tr>
|
||||
<td>Ugrađeni delovi i materijal</td>
|
||||
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .ImaAvans}}
|
||||
<tr>
|
||||
<td>Avans (plaćeno)</td>
|
||||
<td class="desno">− {{.Nalog.AvansStr}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="naplata-blok">
|
||||
<div class="naplata-labela">Za naplatu pri preuzimanju:</div>
|
||||
<div class="naplata-iznos">{{dinari .PreostaloSve}} din</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- garancija -->
|
||||
{{if .Nalog.GarancijaDo}}
|
||||
<div class="odeljak">
|
||||
<div class="garancija-blok">
|
||||
<div class="garancija-tekst">Garancija na izvršeni servis važi do:</div>
|
||||
<div class="garancija-datum">{{.Nalog.GarancijaDo.Format "02.01.2006."}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Nalog.Napomena}}
|
||||
<div class="napomena-dno"><strong>Napomena:</strong> {{.Nalog.Napomena}}</div>
|
||||
{{end}}
|
||||
|
||||
<!-- potpisi -->
|
||||
<div class="potpisi">
|
||||
<div>
|
||||
<div class="potpis-linija">Predao (tehničar / firma)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="potpis-linija">Preuzeo (klijent)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button class="dugme-stampa" onclick="window.print()">Štampaj</button>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,263 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Predračun — {{.Nalog.BrojNaloga}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 13px; color: #111; background: #fff; }
|
||||
|
||||
.strana { max-width: 780px; margin: 0 auto; padding: 32px 40px; }
|
||||
|
||||
/* zaglavlje */
|
||||
.zaglavlje { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 2px solid #111; gap: 20px; }
|
||||
.firma-naziv { font-size: 20px; font-weight: 700; }
|
||||
.firma-info { font-size: 11px; color: #555; margin-top: 4px; line-height: 1.7; }
|
||||
.dok-naslov { text-align: right; }
|
||||
.dok-tip { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #555; margin-bottom: 4px; }
|
||||
.dok-broj { font-size: 22px; font-weight: 700; font-family: monospace; }
|
||||
.dok-datum { font-size: 12px; color: #555; margin-top: 6px; }
|
||||
|
||||
/* strane */
|
||||
.strane-blok { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||
.strana-kartica { padding: 12px 14px; border: 0.5px solid #ddd; border-radius: 6px; }
|
||||
.strana-tip { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #888; margin-bottom: 6px; }
|
||||
.strana-naziv { font-size: 14px; font-weight: 600; }
|
||||
.strana-info { font-size: 12px; color: #555; margin-top: 3px; line-height: 1.6; }
|
||||
|
||||
/* odeljci */
|
||||
.odeljak { margin-bottom: 18px; }
|
||||
.odeljak-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: #555; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 0.5px solid #ccc; }
|
||||
|
||||
/* podaci */
|
||||
.podaci-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.polje-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 3px; }
|
||||
.polje-vrednost { font-size: 13px; font-weight: 500; }
|
||||
.polje-vrednost-mono { font-family: monospace; font-size: 13px; font-weight: 500; }
|
||||
.polje-tekst { font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||
.prazno { color: #aaa; font-style: italic; }
|
||||
|
||||
/* tabela delova */
|
||||
.tabela { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
||||
.tabela th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: #555; padding: 6px 8px; border-bottom: 1.5px solid #111; text-align: left; }
|
||||
.tabela th.desno { text-align: right; }
|
||||
.tabela td { padding: 7px 8px; border-bottom: 0.5px solid #ddd; font-size: 13px; vertical-align: middle; }
|
||||
.tabela td.desno { text-align: right; }
|
||||
.tabela .ukupno-red td { font-weight: 600; border-top: 1.5px solid #111; border-bottom: none; background: #fafafa; }
|
||||
|
||||
/* iznos procene */
|
||||
.naplata-blok { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border: 1.5px solid #111; border-radius: 6px; margin-top: 12px; }
|
||||
.naplata-labela { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.naplata-iznos { font-size: 22px; font-weight: 700; }
|
||||
|
||||
/* rok važenja */
|
||||
.rok-blok { padding: 10px 14px; border: 0.5px solid #fcd34d; background: #fffbeb; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.rok-tekst { font-size: 12px; color: #92400e; font-weight: 500; }
|
||||
.rok-datum { font-size: 15px; font-weight: 700; color: #92400e; }
|
||||
|
||||
/* potpisi */
|
||||
.potpisi { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 36px; padding-top: 16px; border-top: 0.5px solid #ccc; }
|
||||
.potpis-linija { border-top: 1px solid #888; margin-top: 44px; padding-top: 6px; font-size: 11px; color: #555; text-align: center; }
|
||||
|
||||
/* napomena na dnu */
|
||||
.napomena-dno { margin-top: 20px; padding: 10px 14px; background: #f9fafb; border-left: 3px solid #ddd; font-size: 12px; color: #555; line-height: 1.6; }
|
||||
|
||||
@media print {
|
||||
body { font-size: 12px; }
|
||||
.strana { padding: 16px 20px; max-width: 100%; }
|
||||
.dugme-stampa { display: none !important; }
|
||||
@page { size: A4; margin: 12mm 14mm; }
|
||||
}
|
||||
.dugme-stampa { position: fixed; bottom: 24px; right: 24px; background: #111; color: #fff; border: none; padding: 12px 22px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
|
||||
.dugme-stampa:hover { background: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="strana">
|
||||
|
||||
<!-- zaglavlje -->
|
||||
<div class="zaglavlje">
|
||||
<div>
|
||||
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
|
||||
<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}}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-start;gap:14px;">
|
||||
<div class="dok-naslov">
|
||||
<div class="dok-tip">Predračun / Ponuda</div>
|
||||
<div class="dok-broj">{{.Nalog.BrojNaloga}}</div>
|
||||
<div class="dok-datum">Datum izdavanja: {{.DatumIzdavanja.Format "02.01.2006."}}</div>
|
||||
<div class="dok-datum">Važi do: {{.VaziDo.Format "02.01.2006."}}</div>
|
||||
</div>
|
||||
{{if .QRKod}}
|
||||
<img src="data:image/png;base64,{{.QRKod}}" width="76" height="76"
|
||||
alt="QR {{.Nalog.BrojNaloga}}"
|
||||
style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- izdavalac i klijent -->
|
||||
<div class="strane-blok">
|
||||
<div class="strana-kartica">
|
||||
<div class="strana-tip">Izdaje</div>
|
||||
<div class="strana-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}—{{end}}</div>
|
||||
<div class="strana-info">
|
||||
{{if .Adresa}}{{.Adresa}}<br>{{end}}
|
||||
{{if .Telefon}}{{telefon .Telefon}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="strana-kartica">
|
||||
<div class="strana-tip">Za klijenta</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}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="strana-naziv prazno">— klijent nije naveden —</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- uređaj na servisu -->
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Uređaj na servisu</div>
|
||||
<div class="podaci-grid" style="margin-bottom:10px;">
|
||||
<div>
|
||||
<div class="polje-labela">Naziv uređaja</div>
|
||||
<div class="polje-vrednost">{{.Nalog.Uredjaj}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="polje-labela">Serijski broj</div>
|
||||
<div class="polje-vrednost-mono">{{if .Nalog.SerijskiBroj}}{{.Nalog.SerijskiBroj}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||
</div>
|
||||
{{if .TehnicarNaziv}}
|
||||
<div>
|
||||
<div class="polje-labela">Tehničar</div>
|
||||
<div class="polje-vrednost">{{.TehnicarNaziv}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- opis kvara -->
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Utvrđeni kvar</div>
|
||||
<div class="polje-tekst">{{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||
</div>
|
||||
|
||||
<!-- predloženi delovi -->
|
||||
{{if .ServisniDelovi}}
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Predloženi delovi i materijal</div>
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikal</th>
|
||||
<th class="desno" style="width:70px;">Kol.</th>
|
||||
<th class="desno" style="width:120px;">Cena/kom</th>
|
||||
<th class="desno" style="width:120px;">Ukupno</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .ServisniDelovi}}
|
||||
<tr>
|
||||
<td>{{.ArtikalNaziv}}</td>
|
||||
<td class="desno">{{.Kolicina}}</td>
|
||||
<td class="desno">{{dinari .CenaKomada}} din</td>
|
||||
<td class="desno">{{dinari .Ukupno}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr class="ukupno-red">
|
||||
<td colspan="3">Ukupno delovi</td>
|
||||
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- procena troška -->
|
||||
{{if .ImaCenuRada}}
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Procena troška</div>
|
||||
<table class="tabela" style="margin-bottom:10px;">
|
||||
<tbody>
|
||||
{{if .CenaRaspon}}
|
||||
<tr>
|
||||
<td>Cena rada (procena)</td>
|
||||
<td class="desno" style="width:200px;">{{dinari .CenaRadaOd}} – {{dinari .CenaRadaDo}} din</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td>Cena rada</td>
|
||||
<td class="desno" style="width:200px;">{{dinari .CenaRada}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if gt .UkupnoDelovi 0.0}}
|
||||
<tr>
|
||||
<td>Predloženi delovi i materijal</td>
|
||||
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="naplata-blok">
|
||||
<div class="naplata-labela">Procena ukupnog troška:</div>
|
||||
{{if .CenaRaspon}}
|
||||
<div class="naplata-iznos">{{dinari .UkupnoOd}} – {{dinari .UkupnoDo}} din</div>
|
||||
{{else}}
|
||||
<div class="naplata-iznos">{{dinari .Ukupno}} din</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="odeljak">
|
||||
<div class="napomena-dno">Procena troška još nije utvrđena.</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- rok važenja -->
|
||||
<div class="odeljak">
|
||||
<div class="rok-blok">
|
||||
<div class="rok-tekst">Ova ponuda važi do:</div>
|
||||
<div class="rok-datum">{{.VaziDo.Format "02.01.2006."}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Nalog.Napomena}}
|
||||
<div class="napomena-dno"><strong>Napomena:</strong> {{.Nalog.Napomena}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="napomena-dno">
|
||||
Ovo je predračun (ponuda) i ne predstavlja fiskalni dokument. Navedene cene su procena na osnovu
|
||||
utvrđenog kvara; konačan iznos može odstupati ako se tokom servisa otkriju dodatni kvarovi.
|
||||
Servis se započinje nakon saglasnosti klijenta.
|
||||
</div>
|
||||
|
||||
<!-- potpisi -->
|
||||
<div class="potpisi">
|
||||
<div>
|
||||
<div class="potpis-linija">Ponudu izdao (firma)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="potpis-linija">Saglasan sa ponudom (klijent)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button class="dugme-stampa" onclick="window.print()">Štampaj</button>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,255 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Servisni nalog {{.Nalog.BrojNaloga}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 13px; color: #111; background: #fff; }
|
||||
|
||||
.strana { max-width: 780px; margin: 0 auto; padding: 32px 40px; }
|
||||
|
||||
/* zaglavlje */
|
||||
.zaglavlje { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 28px; padding-bottom: 16px; border-bottom: 2px solid #111; }
|
||||
.firma-naziv { font-size: 20px; font-weight: 700; }
|
||||
.firma-info { font-size: 11px; color: #555; margin-top: 4px; line-height: 1.6; }
|
||||
.nalog-broj { text-align: right; }
|
||||
.nalog-broj-vrednost { font-size: 22px; font-weight: 700; font-family: monospace; }
|
||||
.nalog-naslov { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||
|
||||
/* odeljci */
|
||||
.odeljak { margin-bottom: 20px; }
|
||||
.odeljak-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: #555; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 0.5px solid #ccc; }
|
||||
|
||||
/* grid sa podacima */
|
||||
.podaci-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.podaci-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.polje-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 3px; }
|
||||
.polje-vrednost { font-size: 13px; font-weight: 500; }
|
||||
.polje-vrednost-mono { font-family: monospace; font-size: 13px; }
|
||||
.polje-tekst { font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||
|
||||
/* status bedž */
|
||||
.status { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; border: 1.5px solid #111; }
|
||||
|
||||
/* tabela delova */
|
||||
.tabela { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||
.tabela th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: #555; padding: 6px 8px; border-bottom: 1.5px solid #111; text-align: left; }
|
||||
.tabela th.desno { text-align: right; }
|
||||
.tabela td { padding: 7px 8px; border-bottom: 0.5px solid #ddd; font-size: 13px; vertical-align: middle; }
|
||||
.tabela td.desno { text-align: right; }
|
||||
.tabela .ukupno-red td { font-weight: 600; border-top: 1.5px solid #111; border-bottom: none; }
|
||||
|
||||
/* cene */
|
||||
.cene-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.cena-blok { padding: 10px; border: 0.5px solid #ddd; border-radius: 6px; }
|
||||
.cena-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 4px; }
|
||||
.cena-vrednost { font-size: 16px; font-weight: 700; }
|
||||
.cena-konacna { border-color: #111; }
|
||||
|
||||
/* potpisi */
|
||||
.potpisi { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 32px; padding-top: 16px; border-top: 0.5px solid #ccc; }
|
||||
.potpis-linija { border-top: 1px solid #888; margin-top: 40px; padding-top: 6px; font-size: 11px; color: #555; }
|
||||
|
||||
/* štampa */
|
||||
@media print {
|
||||
body { font-size: 12px; }
|
||||
.strana { padding: 16px 20px; max-width: 100%; }
|
||||
.dugme-stampa { display: none !important; }
|
||||
@page { size: A4; margin: 12mm 14mm; }
|
||||
}
|
||||
|
||||
.dugme-stampa { position: fixed; bottom: 24px; right: 24px; background: #111; color: #fff; border: none; padding: 12px 22px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
|
||||
.dugme-stampa:hover { background: #333; }
|
||||
|
||||
.prazno { color: #aaa; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="strana">
|
||||
|
||||
<!-- zaglavlje firme i broj naloga -->
|
||||
<div class="zaglavlje">
|
||||
<div>
|
||||
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
|
||||
<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}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="nalog-broj">
|
||||
<div style="display:flex;align-items:flex-start;gap:14px;justify-content:flex-end;">
|
||||
<div>
|
||||
<div class="nalog-naslov">Servisni nalog</div>
|
||||
<div class="nalog-broj-vrednost">{{.Nalog.BrojNaloga}}</div>
|
||||
<div style="margin-top:6px;"><span class="status">{{.Nalog.Status}}</span></div>
|
||||
</div>
|
||||
{{if .QRKod}}
|
||||
<img src="data:image/png;base64,{{.QRKod}}" width="80" height="80"
|
||||
alt="QR {{.Nalog.BrojNaloga}}"
|
||||
style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;">
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- datumi i klijent -->
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Osnovni podaci</div>
|
||||
<div class="podaci-grid">
|
||||
<div>
|
||||
<div class="polje-labela">Datum prijema</div>
|
||||
<div class="polje-vrednost">{{.Nalog.DatumPrijema.Format "02.01.2006. u 15:04"}}</div>
|
||||
</div>
|
||||
{{if .Nalog.DatumZavrsetka}}
|
||||
<div>
|
||||
<div class="polje-labela">Datum završetka</div>
|
||||
<div class="polje-vrednost">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Nalog.GarancijaDo}}
|
||||
<div>
|
||||
<div class="polje-labela">Garancija do</div>
|
||||
<div class="polje-vrednost">{{.Nalog.GarancijaDo.Format "02.01.2006."}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div>
|
||||
<div class="polje-labela">Klijent</div>
|
||||
<div class="polje-vrednost">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||
</div>
|
||||
{{if .TehnicarNaziv}}
|
||||
<div>
|
||||
<div class="polje-labela">Tehničar</div>
|
||||
<div class="polje-vrednost">{{.TehnicarNaziv}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- uređaj -->
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Uređaj</div>
|
||||
<div class="podaci-grid" style="margin-bottom:12px;">
|
||||
<div>
|
||||
<div class="polje-labela">Naziv uređaja</div>
|
||||
<div class="polje-vrednost">{{.Nalog.Uredjaj}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="polje-labela">Serijski broj</div>
|
||||
<div class="polje-vrednost-mono">{{if .Nalog.SerijskiBroj}}{{.Nalog.SerijskiBroj}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||
</div>
|
||||
{{if .Nalog.PinUredjaja}}
|
||||
<div>
|
||||
<div class="polje-labela">PIN / lozinka</div>
|
||||
<div class="polje-vrednost-mono">{{.Nalog.PinUredjaja}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div class="polje-labela">Opis kvara</div>
|
||||
<div class="polje-tekst">{{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}<span class="prazno">—</span>{{end}}</div>
|
||||
</div>
|
||||
{{if or .Nalog.Ostecenja .Nalog.Pribor}}
|
||||
<div class="podaci-grid-2">
|
||||
{{if .Nalog.Ostecenja}}
|
||||
<div>
|
||||
<div class="polje-labela">Oštećenja pri prijemu</div>
|
||||
<div class="polje-tekst">{{.Nalog.Ostecenja}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Nalog.Pribor}}
|
||||
<div>
|
||||
<div class="polje-labela">Pribor i oprema</div>
|
||||
<div class="polje-tekst">{{.Nalog.Pribor}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- ugrađeni delovi -->
|
||||
{{if .ServisniDelovi}}
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Ugrađeni delovi</div>
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikal</th>
|
||||
<th class="desno" style="width:80px;">Kol.</th>
|
||||
<th class="desno" style="width:130px;">Cena/kom</th>
|
||||
<th class="desno" style="width:130px;">Ukupno</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .ServisniDelovi}}
|
||||
<tr>
|
||||
<td>{{.ArtikalNaziv}}</td>
|
||||
<td class="desno">{{.Kolicina}}</td>
|
||||
<td class="desno">{{dinari .CenaKomada}} din</td>
|
||||
<td class="desno">{{dinari .Ukupno}} din</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr class="ukupno-red">
|
||||
<td colspan="3">Ukupno delovi</td>
|
||||
<td class="desno">{{dinari .UkupnoDelovi}} din</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- cene -->
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Cene usluge</div>
|
||||
<div class="cene-grid">
|
||||
<div class="cena-blok">
|
||||
<div class="cena-labela">Procena od</div>
|
||||
<div class="cena-vrednost">{{if .Nalog.CenaOd}}{{.Nalog.CenaOdStr}} din{{else}}—{{end}}</div>
|
||||
</div>
|
||||
<div class="cena-blok">
|
||||
<div class="cena-labela">Procena do</div>
|
||||
<div class="cena-vrednost">{{if .Nalog.CenaDo}}{{.Nalog.CenaDoStr}} din{{else}}—{{end}}</div>
|
||||
</div>
|
||||
<div class="cena-blok">
|
||||
<div class="cena-labela">Avans</div>
|
||||
<div class="cena-vrednost">{{if .Nalog.Avans}}{{.Nalog.AvansStr}} din{{else}}—{{end}}</div>
|
||||
</div>
|
||||
<div class="cena-blok cena-konacna">
|
||||
<div class="cena-labela">Cena rada</div>
|
||||
<div class="cena-vrednost">{{if .Nalog.CenaKonacna}}{{.Nalog.CenaKonacnaStr}} din{{else}}—{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Nalog.PreostaloZaNaplatu}}
|
||||
<div style="margin-top:10px;padding:10px 14px;background:#f0fdf4;border:1px solid #86efac;border-radius:6px;display:flex;justify-content:space-between;align-items:center;">
|
||||
<span style="font-size:12px;color:#15803d;font-weight:500;">Preostalo za naplatu:</span>
|
||||
<span style="font-size:18px;font-weight:700;color:#15803d;">{{.Nalog.PreostaloZaNaplatuStr}} din</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Nalog.Napomena}}
|
||||
<div class="odeljak">
|
||||
<div class="odeljak-naslov">Napomena</div>
|
||||
<div class="polje-tekst" style="color:#555;">{{.Nalog.Napomena}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- potpisi -->
|
||||
<div class="potpisi">
|
||||
<div>
|
||||
<div class="potpis-linija">Predao klijent</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="potpis-linija">Primio tehničar</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button class="dugme-stampa" onclick="window.print()">Štampaj</button>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Status popravke — {{.Nalog.BrojNaloga}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 14px; color: #111; background: #f5f5f7; min-height: 100vh; }
|
||||
|
||||
.omotac { max-width: 480px; margin: 0 auto; padding: 24px 16px 48px; }
|
||||
|
||||
/* zaglavlje firme */
|
||||
.zaglavlje { text-align: center; margin-bottom: 24px; }
|
||||
.firma-naziv { font-size: 18px; font-weight: 700; color: #111; }
|
||||
.firma-info { font-size: 12px; color: #666; margin-top: 4px; line-height: 1.6; }
|
||||
|
||||
/* kartica naloga */
|
||||
.kartica { background: #fff; border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||
.kartica-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #999; margin-bottom: 12px; }
|
||||
|
||||
.uredjaj { font-size: 22px; font-weight: 700; color: #111; line-height: 1.2; }
|
||||
.broj-naloga { font-size: 13px; color: #888; margin-top: 4px; font-family: monospace; }
|
||||
|
||||
/* status bedž */
|
||||
.status-bedz {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.status-primljeno { background: #e8f0fe; color: #1a56db; }
|
||||
.status-dijagnostika { background: #fef3c7; color: #b45309; }
|
||||
.status-ceka { background: #fee2e2; color: #dc2626; }
|
||||
.status-popravka { background: #fef3c7; color: #b45309; }
|
||||
.status-zavrseno { background: #d1fae5; color: #059669; }
|
||||
.status-preuzeto { background: #f3f4f6; color: #374151; }
|
||||
|
||||
/* napredak */
|
||||
.napredak { margin: 16px 0 4px; }
|
||||
.napredak-labela { font-size: 11px; color: #999; margin-bottom: 8px; }
|
||||
.koraci { display: flex; gap: 4px; }
|
||||
.korak {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.korak.aktivan { background: #10b981; }
|
||||
.korak.zavrseno { background: #10b981; }
|
||||
.korak.tekuci { background: #f59e0b; }
|
||||
|
||||
/* red podataka */
|
||||
.red { display: flex; justify-content: space-between; align-items: flex-start; padding: 10px 0; border-bottom: 0.5px solid #f0f0f0; gap: 12px; }
|
||||
.red:last-child { border-bottom: none; }
|
||||
.red-labela { font-size: 12px; color: #888; flex-shrink: 0; }
|
||||
.red-vrednost { font-size: 13px; color: #111; font-weight: 500; text-align: right; }
|
||||
|
||||
/* cena */
|
||||
.cena-blok { background: #f9fafb; border-radius: 10px; padding: 14px 16px; margin-top: 4px; }
|
||||
.cena-od-do { display: flex; gap: 8px; align-items: center; }
|
||||
.cena-od-do .cena-vrednost { font-size: 20px; font-weight: 700; color: #111; }
|
||||
.cena-od-do .cena-sep { color: #bbb; }
|
||||
.cena-labela { font-size: 11px; color: #888; margin-top: 2px; }
|
||||
|
||||
/* kontakt */
|
||||
.kontakt { background: #fff; border-radius: 16px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||
.kontakt-naslov { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: #999; margin-bottom: 12px; }
|
||||
.kontakt-tel { font-size: 18px; font-weight: 700; color: #1a56db; text-decoration: none; display: block; }
|
||||
.kontakt-adresa { font-size: 13px; color: #666; margin-top: 6px; line-height: 1.5; }
|
||||
|
||||
/* napomena za klijenta */
|
||||
.napomena-blok { background: #fffbeb; border-radius: 10px; padding: 12px 14px; margin-top: 4px; font-size: 13px; color: #555; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="omotac">
|
||||
|
||||
<!-- zaglavlje firme -->
|
||||
<div class="zaglavlje">
|
||||
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}Servis{{end}}</div>
|
||||
{{if .Adresa}}<div class="firma-info">{{.Adresa}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<!-- kartica: uređaj i status -->
|
||||
<div class="kartica">
|
||||
<div class="kartica-naslov">Vaš uređaj</div>
|
||||
<div class="uredjaj">{{.Nalog.Uredjaj}}</div>
|
||||
<div class="broj-naloga">Nalog: {{.Nalog.BrojNaloga}}</div>
|
||||
|
||||
{{$s := .Nalog.Status}}
|
||||
{{if eq $s "Primljeno"}}
|
||||
<span class="status-bedz status-primljeno">Primljeno</span>
|
||||
{{else if eq $s "U dijagnostici"}}
|
||||
<span class="status-bedz status-dijagnostika">U dijagnostici</span>
|
||||
{{else if eq $s "Čeka delove"}}
|
||||
<span class="status-bedz status-ceka">Čeka delove</span>
|
||||
{{else if eq $s "U popravci"}}
|
||||
<span class="status-bedz status-popravka">U popravci</span>
|
||||
{{else if eq $s "Završeno"}}
|
||||
<span class="status-bedz status-zavrseno">Završeno — možete preuzeti</span>
|
||||
{{else if eq $s "Preuzeto"}}
|
||||
<span class="status-bedz status-preuzeto">Preuzeto</span>
|
||||
{{end}}
|
||||
|
||||
<!-- traka napretka -->
|
||||
<div class="napredak">
|
||||
<div class="napredak-labela">Napredak popravke</div>
|
||||
<div class="koraci">
|
||||
{{range $i, $korak := .SviStatusi}}
|
||||
{{if eq $korak $s}}
|
||||
<div class="korak tekuci"></div>
|
||||
{{else if statusPre $korak $s $.SviStatusi}}
|
||||
<div class="korak zavrseno"></div>
|
||||
{{else}}
|
||||
<div class="korak"></div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kartica: detalji -->
|
||||
<div class="kartica">
|
||||
<div class="kartica-naslov">Detalji</div>
|
||||
|
||||
<div class="red">
|
||||
<span class="red-labela">Datum prijema</span>
|
||||
<span class="red-vrednost">{{.Nalog.DatumPrijema.Format "02.01.2006."}}</span>
|
||||
</div>
|
||||
|
||||
{{if .Nalog.DatumZavrsetka}}
|
||||
<div class="red">
|
||||
<span class="red-labela">Datum završetka</span>
|
||||
<span class="red-vrednost">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Nalog.SerijskiBroj}}
|
||||
<div class="red">
|
||||
<span class="red-labela">Serijski broj</span>
|
||||
<span class="red-vrednost">{{.Nalog.SerijskiBroj}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Nalog.OpisKvara}}
|
||||
<div class="red">
|
||||
<span class="red-labela">Opis kvara</span>
|
||||
<span class="red-vrednost">{{.Nalog.OpisKvara}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Nalog.GarancijaDo}}
|
||||
<div class="red">
|
||||
<span class="red-labela">Garancija do</span>
|
||||
<span class="red-vrednost">{{.Nalog.GarancijaDo.Format "02.01.2006."}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- procenjena cena -->
|
||||
{{if or .Nalog.CenaOd .Nalog.CenaDo .Nalog.CenaKonacna}}
|
||||
<div style="margin-top:12px;">
|
||||
<div class="red-labela" style="margin-bottom:8px;">Procena cene</div>
|
||||
<div class="cena-blok">
|
||||
{{if .Nalog.CenaKonacna}}
|
||||
<div class="cena-od-do">
|
||||
<span class="cena-vrednost">{{formatBroj .Nalog.CenaKonacna}} din</span>
|
||||
</div>
|
||||
<div class="cena-labela">Konačna cena popravke</div>
|
||||
{{else if and .Nalog.CenaOd .Nalog.CenaDo}}
|
||||
<div class="cena-od-do">
|
||||
<span class="cena-vrednost">{{formatBroj .Nalog.CenaOd}}</span>
|
||||
<span class="cena-sep">–</span>
|
||||
<span class="cena-vrednost">{{formatBroj .Nalog.CenaDo}} din</span>
|
||||
</div>
|
||||
<div class="cena-labela">Procenjeni raspon cene</div>
|
||||
{{else if .Nalog.CenaOd}}
|
||||
<div class="cena-od-do">
|
||||
<span class="cena-vrednost">od {{formatBroj .Nalog.CenaOd}} din</span>
|
||||
</div>
|
||||
<div class="cena-labela">Procenjena cena od</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Nalog.Napomena}}
|
||||
<div style="margin-top:12px;">
|
||||
<div class="red-labela" style="margin-bottom:6px;">Napomena</div>
|
||||
<div class="napomena-blok">{{.Nalog.Napomena}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- kontakt -->
|
||||
{{if .Telefon}}
|
||||
<div class="kontakt">
|
||||
<div class="kontakt-naslov">Kontakt</div>
|
||||
<a href="tel:{{.Telefon}}" class="kontakt-tel">{{telefon .Telefon}}</a>
|
||||
{{if .Adresa}}<div class="kontakt-adresa">{{.Adresa}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Podešavanje — NTech</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "naslov"}}Stanje zaliha — NTech{{end}}
|
||||
|
||||
{{define "sadrzaj"}}
|
||||
<div class="kolona" style="gap:16px;">
|
||||
|
||||
<!-- zaglavlje -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<a href="/izvestaji" class="nazad-link" style="margin-bottom:0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
Izveštaji
|
||||
</a>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;">
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:12px;color:var(--tekst-slabi);">Broj artikala</div>
|
||||
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{.BrojArtikala}}</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:12px;color:var(--tekst-slabi);">Ukupna vrednost zalihe</div>
|
||||
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{dinari .UkupnaVrednost}} din</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tabela -->
|
||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||
<div style="padding:14px 20px;border-bottom:0.5px solid var(--ivica);">
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Stanje zaliha</span>
|
||||
</div>
|
||||
|
||||
{{if not .Zalihe}}
|
||||
<div style="padding:40px;text-align:center;color:var(--tekst-slabi);font-size:14px;">
|
||||
Nema artikala u magacinu.
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikal</th>
|
||||
<th>Šifra</th>
|
||||
<th>Kategorija</th>
|
||||
<th style="text-align:right;">Stanje</th>
|
||||
<th style="text-align:right;">Min.</th>
|
||||
<th style="text-align:right;">Nab. cena</th>
|
||||
<th style="text-align:right;">Prod. cena</th>
|
||||
<th style="text-align:right;">Vrednost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Zalihe}}
|
||||
<tr {{if le .Kolicina .KolicinMin}}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-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="text-align:right;font-weight:600;{{if le .Kolicina .KolicinMin}}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-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-weight:500;">{{dinari .VrednostZalihe}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="border-top:1.5px solid var(--ivica);font-weight:600;">
|
||||
<td colspan="7" style="padding:10px 12px;font-size:13px;">Ukupna vrednost zalihe</td>
|
||||
<td style="text-align:right;padding:10px 12px;font-family:monospace;">{{dinari .UkupnaVrednost}} din</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dvostepena verifikacija — NTech</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-skupljen') === 'true') {
|
||||
document.documentElement.classList.add('sidebar-init-skupljen');
|
||||
}
|
||||
// sakriva poruke-uspeha odmah da ne trepnu pre toast konverzije
|
||||
document.documentElement.classList.add('js-on');
|
||||
</script>
|
||||
|
||||
<!-- tema — učitava se prva -->
|
||||
@@ -26,6 +28,14 @@
|
||||
|
||||
{{if .AppPozadina}}
|
||||
<style>
|
||||
:root {
|
||||
--app-blur-bg: {{.AppPozadinaBlurPozadine}}px;
|
||||
--app-blur-bg-inset: {{if ne .AppPozadinaBlurPozadine "0"}}-20px{{else}}0{{end}};
|
||||
--app-overlay: {{.AppPozadinaOpacity}}%;
|
||||
--app-blur: {{.AppPozadinaBlur}}px;
|
||||
--app-glass-sb: {{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}30%{{end}};
|
||||
--app-glass-el: {{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}8%{{end}};
|
||||
}
|
||||
html {
|
||||
background: url('{{.AppPozadina}}') center/cover fixed;
|
||||
background-color: #1f2228;
|
||||
@@ -34,9 +44,9 @@
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: {{if ne .AppPozadinaBlurPozadine "0"}}-20px{{else}}0{{end}};
|
||||
inset: var(--app-blur-bg-inset);
|
||||
background: url('{{.AppPozadina}}') center/cover;
|
||||
filter: blur({{.AppPozadinaBlurPozadine}}px);
|
||||
filter: blur(var(--app-blur-bg));
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -44,18 +54,20 @@
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,{{.AppPozadinaOpacity}}%);
|
||||
background: rgba(0,0,0,var(--app-overlay));
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.raspored { position: relative; z-index: 2; }
|
||||
.sidebar { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.3{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border-right: 1px solid rgba(255,255,255,0.12) !important; }
|
||||
.sidebar { background: rgba(0,0,0,var(--app-glass-sb)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border-right: 1px solid rgba(255,255,255,0.12) !important; }
|
||||
.sidebar .nav-stavka, .sidebar .logo-naziv, .sidebar .logo-podnazlov { text-shadow: 0 1px 3px rgba(0,0,0,0.8); color: rgba(255,255,255,0.95) !important; }
|
||||
.sidebar .nav-stavka svg { color: rgba(255,255,255,0.95) !important; stroke: rgba(255,255,255,0.95) !important; }
|
||||
.sidebar .nav-oznaka { text-shadow: 0 1px 3px rgba(0,0,0,0.8); color: rgba(255,255,255,0.7) !important; }
|
||||
.topbar { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.08{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border-bottom: 1px solid rgba(255,255,255,0.12) !important; }
|
||||
.topbar { background: rgba(0,0,0,var(--app-glass-el)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border-bottom: 1px solid rgba(255,255,255,0.12) !important; }
|
||||
.topbar-naslov { color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 2px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.55); }
|
||||
.kartica, .premesti-modal { background: rgba(0,0,0,{{if .AppPozadinaGlassOpacity}}{{.AppPozadinaGlassOpacity}}%{{else}}0.08{{end}}) !important; backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px); border: 1px solid rgba(255,255,255,0.12) !important; }
|
||||
.kartica, .premesti-modal { background: rgba(0,0,0,var(--app-glass-el)) !important; backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur)); border: 1px solid rgba(255,255,255,0.12) !important; }
|
||||
body:not([data-hover]) .kartica:hover { background: rgba(0,0,0,0.38) !important; }
|
||||
body:not([data-hover]) .kartica:hover .dash-ikona { filter: brightness(0.55); }
|
||||
.kartica p, .kartica span, .kartica h1, .kartica h2, .kartica h3, .kartica h4, .kartica label, .kartica td, .kartica th, .kartica li, .kartica a { color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 2px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.55); }
|
||||
/* naslovi kartica i labele su goli <div> (boju im NE diramo, da namerno obojeni — crveni/akcenat — ostanu) — samo senka da se vide */
|
||||
.kartica div { text-shadow: 0 1px 2px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.55); }
|
||||
@@ -71,7 +83,7 @@
|
||||
dobijaju sopstvenu staklenu podlogu da se vide bez obzira na sliku ispod */
|
||||
.nazad-link, .btn-sekundarno, .btn-obrisi-ghost, .cek-filter {
|
||||
background: rgba(0,0,0,0.28) !important;
|
||||
backdrop-filter: blur({{.AppPozadinaBlur}}px); -webkit-backdrop-filter: blur({{.AppPozadinaBlur}}px);
|
||||
backdrop-filter: blur(var(--app-blur)); -webkit-backdrop-filter: blur(var(--app-blur));
|
||||
border: 1px solid rgba(255,255,255,0.18) !important;
|
||||
color: rgba(255,255,255,0.95) !important; text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
@@ -106,7 +118,7 @@
|
||||
</script>
|
||||
{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<body{{if .LokalnaAnimacija}} data-animacija="{{.LokalnaAnimacija}}"{{end}}{{if .LokalniHover}} data-hover="{{.LokalniHover}}"{{end}}{{if .LokalnaBrzinaAnimacije}} data-brzina-animacije="{{.LokalnaBrzinaAnimacije}}"{{end}}>
|
||||
<div class="raspored">
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
{{template "sidebar" .}}
|
||||
@@ -224,9 +236,92 @@
|
||||
|
||||
{{block "dodatni-js" .}}{{end}}
|
||||
|
||||
<!-- modal za potvrdu akcije -->
|
||||
<div id="potvrda-modal" style="display:none;position:fixed;inset:0;z-index:9999;align-items:center;justify-content:center;">
|
||||
<div id="potvrda-pozadina" style="position:absolute;inset:0;background:rgba(0,0,0,0.55);backdrop-filter:blur(3px);"></div>
|
||||
<div style="position:relative;background:var(--kartica-pozadina);border:0.5px solid var(--ivica);border-radius:12px;padding:28px 28px 22px;max-width:380px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,0.35);">
|
||||
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:20px;line-height:1.5;" id="potvrda-poruka"></div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||
<button id="potvrda-odustani" class="btn-sekundarno">Odustani</button>
|
||||
<button id="potvrda-potvrdi" class="btn-opasno">Potvrdi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSRF i potvrda: inicijalizacija na učitavanju i posle htmx swap-a -->
|
||||
<script>
|
||||
(function() {
|
||||
var modal = document.getElementById('potvrda-modal');
|
||||
var poruka = document.getElementById('potvrda-poruka');
|
||||
var btnPotvrdi = document.getElementById('potvrda-potvrdi');
|
||||
var btnOdustani = document.getElementById('potvrda-odustani');
|
||||
var pozadina = document.getElementById('potvrda-pozadina');
|
||||
var _resolveFn = null;
|
||||
|
||||
function prikaziModal(tekst) {
|
||||
return new Promise(function(resolve) {
|
||||
poruka.textContent = tekst;
|
||||
modal.style.display = 'flex';
|
||||
_resolveFn = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function zatvoriModal(rezultat) {
|
||||
modal.style.display = 'none';
|
||||
if (_resolveFn) { _resolveFn(rezultat); _resolveFn = null; }
|
||||
}
|
||||
|
||||
btnPotvrdi.addEventListener('click', function() { zatvoriModal(true); });
|
||||
btnOdustani.addEventListener('click', function() { zatvoriModal(false); });
|
||||
pozadina.addEventListener('click', function() { zatvoriModal(false); });
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && modal.style.display === 'flex') zatvoriModal(false);
|
||||
});
|
||||
|
||||
window._ntechPotvrdi = prikaziModal;
|
||||
})();
|
||||
|
||||
// toast obaveštenja — prikazuje poruku u uglu ekrana, nestaje posle 4s
|
||||
window.ntechToast = function(tekst, tip) {
|
||||
var t = document.createElement('div');
|
||||
t.className = 'toast ' + (tip === 'greska' ? 'toast-greska' : 'toast-uspeh');
|
||||
// ikonica
|
||||
var svg = tip === 'greska'
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
// SVG je statički literal (bezbedan za innerHTML); korisnički tekst ide
|
||||
// preko textContent da se ne reinterpretira kao HTML (XSS zaštita)
|
||||
t.innerHTML = svg;
|
||||
var tekstSpan = document.createElement('span');
|
||||
tekstSpan.textContent = tekst;
|
||||
t.appendChild(tekstSpan);
|
||||
// prilagođavamo poziciju za mobilne uređaje
|
||||
t.style.bottom = '24px';
|
||||
t.style.right = '16px';
|
||||
document.body.appendChild(t);
|
||||
var ukloni = function() {
|
||||
t.classList.add('nestaje');
|
||||
setTimeout(function() { if (t.parentNode) t.parentNode.removeChild(t); }, 320);
|
||||
};
|
||||
t.addEventListener('click', ukloni);
|
||||
setTimeout(ukloni, 4000);
|
||||
};
|
||||
|
||||
// konvertuje sve .poruka-uspeh u toast i sklanja original
|
||||
function ntechKonvertujPoruke() {
|
||||
document.querySelectorAll('.poruka-uspeh').forEach(function(el) {
|
||||
if (el.dataset.toastPrikazan) return;
|
||||
el.dataset.toastPrikazan = '1';
|
||||
var tekst = el.textContent.trim();
|
||||
el.style.display = 'none';
|
||||
if (tekst) window.ntechToast(tekst, 'uspeh');
|
||||
});
|
||||
}
|
||||
|
||||
function ntechInicijalizuj() {
|
||||
ntechKonvertujPoruke();
|
||||
|
||||
|
||||
var m = document.querySelector('meta[name="csrf-token"]');
|
||||
if (m && m.content) {
|
||||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
|
||||
@@ -236,15 +331,105 @@
|
||||
f.appendChild(i);
|
||||
});
|
||||
}
|
||||
// toast pri učitavanju stranice ako URL sadrži ?sacuvano=1 (cross-page redirect)
|
||||
if (!window._ntechSacuvanoPrikazano && location.search.indexOf('sacuvano=1') !== -1) {
|
||||
window._ntechSacuvanoPrikazano = true;
|
||||
window.ntechToast('Sačuvano', 'uspeh');
|
||||
var _p = new URLSearchParams(location.search);
|
||||
_p.delete('sacuvano');
|
||||
var _q = _p.toString() ? '?' + _p.toString() : '';
|
||||
history.replaceState(null, '', location.pathname + _q + location.hash);
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-potvrda]').forEach(function(el) {
|
||||
if (el._potvrda) return;
|
||||
el._potvrda = true;
|
||||
el.addEventListener('click', function(e) {
|
||||
if (!confirm(el.getAttribute('data-potvrda'))) e.preventDefault();
|
||||
e.preventDefault();
|
||||
window._ntechPotvrdi(el.getAttribute('data-potvrda')).then(function(ok) {
|
||||
if (!ok) return;
|
||||
var forma = el.closest('form');
|
||||
if (forma) { forma.submit(); return; }
|
||||
if (el.href) { window.location.href = el.href; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// AJAX submit — forme koje serveru vraćaju ?sacuvano= ostaju na stranici (scroll se ne gubi)
|
||||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
|
||||
if (f._ajaxSave) return;
|
||||
f._ajaxSave = true;
|
||||
f.addEventListener('submit', function(e) {
|
||||
// file upload i forme sa data-full-reload šalju se normalno
|
||||
if (f.enctype === 'multipart/form-data' || f.hasAttribute('data-full-reload')) return;
|
||||
e.preventDefault();
|
||||
// prikazujemo dugme kao zauzeto
|
||||
var btn = f.querySelector('[type="submit"]');
|
||||
if (btn) btn.disabled = true;
|
||||
// URLSearchParams šalje kao application/x-www-form-urlencoded
|
||||
// što Go-ov r.FormValue() (i CSRF middleware) može da pročita
|
||||
fetch(f.action || location.href, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(new FormData(f)),
|
||||
redirect: 'follow'
|
||||
}).then(function(res) {
|
||||
var finUrl = new URL(res.url);
|
||||
var isStiPath = finUrl.pathname === location.pathname;
|
||||
var imaSacuvano = finUrl.search.indexOf('sacuvano') !== -1;
|
||||
if (isStiPath && imaSacuvano) {
|
||||
// uspeh na istoj stranici — prikaži toast, ostani
|
||||
window.ntechToast('Sačuvano', 'uspeh');
|
||||
if (btn) btn.disabled = false;
|
||||
// odmah primeni podešavanja koja menjaju globalne atribute body-ja
|
||||
var anim = f.querySelector('[name="lokalna_animacija"]');
|
||||
if (anim) {
|
||||
if (anim.value) document.body.dataset.animacija = anim.value;
|
||||
else delete document.body.dataset.animacija;
|
||||
}
|
||||
var hov = f.querySelector('[name="lokalni_hover"]');
|
||||
if (hov) {
|
||||
if (hov.value) document.body.dataset.hover = hov.value;
|
||||
else delete document.body.dataset.hover;
|
||||
}
|
||||
var brzina = f.querySelector('[name="lokalna_brzina_animacije"]');
|
||||
if (brzina) {
|
||||
if (brzina.value) document.body.dataset.brzinaAnimacije = brzina.value;
|
||||
else delete document.body.dataset.brzinaAnimacije;
|
||||
}
|
||||
// toggle „Prikaži logo u gornjoj traci" — pokaži/sakrij logo u topbaru bez reloda
|
||||
var logoToggle = f.querySelector('[name="topbar_logo_slika"]');
|
||||
if (logoToggle) {
|
||||
var logoImg = document.querySelector('.topbar-logo');
|
||||
if (logoImg) logoImg.classList.toggle('skriven', !logoToggle.checked);
|
||||
}
|
||||
// posle čuvanja stilova lične pozadine ažuriraj CSS custom properties
|
||||
var bgBlur = f.querySelector('[name="lokalna_pozadina_blur"]');
|
||||
if (bgBlur) {
|
||||
var r = document.documentElement;
|
||||
var bgBlurBg = parseInt(f.querySelector('[name="lokalna_pozadina_blur_pozadine"]').value) || 0;
|
||||
var bgOp = parseInt(f.querySelector('[name="lokalna_pozadina_opacity"]').value) || 0;
|
||||
var bgGlass = parseInt(f.querySelector('[name="lokalna_pozadina_glass_opacity"]').value) || 0;
|
||||
var bgBlurV = parseInt(bgBlur.value) || 0;
|
||||
r.style.setProperty('--app-blur-bg', bgBlurBg + 'px');
|
||||
r.style.setProperty('--app-blur-bg-inset', bgBlurBg > 0 ? '-20px' : '0');
|
||||
r.style.setProperty('--app-overlay', bgOp + '%');
|
||||
r.style.setProperty('--app-blur', bgBlurV + 'px');
|
||||
r.style.setProperty('--app-glass-sb', bgGlass + '%');
|
||||
r.style.setProperty('--app-glass-el', bgGlass + '%');
|
||||
}
|
||||
// promena teme zahteva reload (menja se ceo CSS fajl)
|
||||
if (f.querySelector('[name="lokalna_tema"]')) location.reload();
|
||||
} else {
|
||||
// redirect na drugu stranicu ili bez sacuvano — navigiraj normalno
|
||||
location.href = res.url;
|
||||
}
|
||||
}).catch(function() {
|
||||
// mrežna greška — pošalji formu normalno
|
||||
f.submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
// htmx:afterSettle listener se dodaje samo jednom — ne sme da se gomila po swap-ovima
|
||||
if (!window._ntechCsrfDodato) {
|
||||
window._ntechCsrfDodato = true;
|
||||
document.addEventListener('htmx:afterSettle', ntechInicijalizuj);
|
||||
|
||||
Reference in New Issue
Block a user