Compare commits

...

2 Commits

Author SHA1 Message Date
668195e642 ne radi vite server 2026-05-23 21:52:15 +00:00
80f9ba3879 vizualizacija i customUser model ima umjesto is_serviser polje uloga 2026-05-23 12:30:03 +00:00
39 changed files with 2165 additions and 687 deletions

View File

@@ -125,6 +125,15 @@ SIMPLE_JWT = {
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# 2. CSRF_TRUSTED_ORIGINS
# Primjer: https://app.tvojadomena.hr,https://api.tvojadomena.hr
CSRF_TRUSTED_ORIGINS = [
origin.strip()
for origin in os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://127.0.0.1:4321,http://localhost:4321').split(',')
if origin.strip()
]
# CORS POSTAVKE - POPRAVLJENI ZAREZI I FORMALNI ORIGINI
if DEBUG:
CORS_ALLOW_ALL_ORIGINS = True

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -17,6 +17,21 @@ class PutniNalogMinimalSerializer(serializers.ModelSerializer):
model = PutniNalog
fields = ['id', 'broj_naloga', 'status', 'pocetna_km']
# --- NOVI STANDARDNI PUTNI NALOG SERIALIZER (Za rješavanje ImportError-a) ---
class PutniNalogSerializer(serializers.ModelSerializer):
vozilo_detalji = VoziloListaSerializer(source='vozilo', read_only=True)
korisnik_ime = serializers.CharField(source='korisnik.get_full_name', read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
class Meta:
model = PutniNalog
fields = [
'id', 'broj_naloga', 'vozilo', 'vozilo_detalji', 'korisnik', 'korisnik_ime',
'relacija', 'mjesto_odredista', 'pocetna_km', 'zavrsna_km', 'status',
'status_display', 'vrijeme_polaska', 'vrijeme_povratka'
]
read_only_fields = ['id', 'broj_naloga', 'vrijeme_polaska']
# --- 1. LISTA SERIALIZER ---
class RadniNalogListaSerializer(serializers.ModelSerializer):
klijent_naziv = serializers.CharField(source='klijent.naziv', read_only=True)

View File

@@ -3,11 +3,21 @@ from django.contrib.auth.admin import UserAdmin
from .models import CustomUser
class CustomUserAdmin(UserAdmin):
model = CustomUser
# Definiramo koja polja se vide u admin listi i formama
list_display = ['email', 'username', 'first_name', 'last_name', 'is_staff', 'is_serviser']
# 🚀 POPRAVLJENO: Zamijenjen 'is_serviser' s 'uloga' u stupcima tablice
list_display = ['email', 'first_name', 'last_name', 'telefon', 'oib', 'uloga', 'is_staff']
# Ako filtriraš korisnike na desnoj strani admina, ažuriraj i to:
list_filter = ['uloga', 'is_staff', 'is_active']
# 🚀 POPRAVLJENO: Dodavanje polja u sekciju za uređivanje postojećeg korisnika
fieldsets = UserAdmin.fieldsets + (
('Dodatni podaci', {'fields': ('telefon', 'oib', 'is_serviser')}),
('Dodatni podaci tvrtke', {'fields': ('telefon', 'oib', 'uloga')}),
)
# 🚀 POPRAVLJENO: Dodavanje polja u formu za kreiranje novog korisnika
add_fieldsets = UserAdmin.add_fieldsets + (
('Dodatni podaci tvrtke', {'fields': ('telefon', 'oib', 'uloga')}),
)
# Registracija modela i tvoje prilagođene admin klase
admin.site.register(CustomUser, CustomUserAdmin)

View File

@@ -0,0 +1,22 @@
# Generated by Django 6.0.5 on 2026-05-23 12:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='customuser',
name='is_serviser',
),
migrations.AddField(
model_name='customuser',
name='uloga',
field=models.CharField(choices=[('SERVISER', 'Serviser'), ('PRODAJA', 'Prodaja / Operativa'), ('KNJIGOVODSTVO', 'Knjigovodstvo i financije'), ('ADMIN', 'Administrator')], default='SERVISER', help_text='Glavna operativna uloga korisnika u ERP sustavu', max_length=20),
),
]

View File

@@ -2,17 +2,29 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
class CustomUser(AbstractUser):
class Role(models.TextChoices):
SERVISER = 'SERVISER', 'Serviser'
PRODAJA = 'PRODAJA', 'Prodaja / Operativa'
KNJIGOVODSTVO = 'KNJIGOVODSTVO', 'Knjigovodstvo i financije'
ADMIN = 'ADMIN', 'Administrator'
# Email koristimo za login, pa mora biti jedinstven
email = models.EmailField(unique=True)
# Dodatna polja za tvoju tvrtku
telefon = models.CharField(max_length=20, blank=True)
oib = models.CharField(max_length=11, blank=True, null=True)
is_serviser = models.BooleanField(default=False) # Razlikovanje uloga
# 🚀 ZAMJENA: Umjesto is_serviser, uvodimo jedno polje za sve uloge
uloga = models.CharField(
max_length=20,
choices=Role.choices,
default=Role.SERVISER,
help_text="Glavna operativna uloga korisnika u ERP sustavu"
)
# Govorimo Djangu da koristi email umjesto username-a
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username', 'first_name', 'last_name']
def __str__(self):
return f"{self.first_name} {self.last_name} ({self.email})"
return f"{self.first_name} {self.last_name} ({self.email}) - {self.get_uloga_display()}"

View File

@@ -1,7 +1,96 @@
from rest_framework import serializers
from .models import CustomUser
from fleet.models import Vozilo
from operations.models import RadniNalog, PutniNalog
class UserSerializer(serializers.ModelSerializer):
# Prosljeđujemo i ljudski čitljiv naziv (npr. "Knjigovodstvo i financije")
uloga_prikaz = serializers.CharField(source='get_uloga_display', read_only=True)
class UserMeSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser']
fields = ['id', 'first_name', 'last_name', 'email', 'telefon', 'oib', 'uloga', 'uloga_prikaz']
# --- POMOĆNI LIGHTWEIGHT SERIALIZERI (Sprečavaju prevelik JSON payload) ---
class ServiserVoziloSerializer(serializers.ModelSerializer):
class Meta:
model = Vozilo
# 🚀 POPRAVLJENO: Izbačena nepostojeća polja (marka, model, tip).
# Usklađeno s tvojim stvarnim poljima iz /fleet/models.py
fields = [
'id',
'naziv',
'registracija',
'trenutni_kilometri',
'status'
]
class ServiserRadniNalogSerializer(serializers.ModelSerializer):
# Prikazujemo nazive umjesto ID-jeva radi lakšeg klijentskog ispisa na terminalu
kupac_naziv = serializers.CharField(source='klijent.naziv', read_only=True)
stroj_naziv = serializers.CharField(source='stroj.naziv', read_only=True)
class Meta:
model = RadniNalog
# 🚀 POPRAVLJENO: 'prioritet' je izbačen jer ne postoji na modelu RadniNalog.
# Dodan je 'opis_kvara' koji ti može zatrebati na klijentskim karticama!
fields = [
'id',
'broj_naloga',
'status',
'opis_kvara',
'datum_kreiranja',
'kupac_naziv',
'stroj_naziv'
]
class ServiserPutniNalogSerializer(serializers.ModelSerializer):
vozilo_registracija = serializers.CharField(source='vozilo.registracija', read_only=True)
# 🚀 Budući da jedan putni nalog može imati više radnih naloga (ili nijedan ako tek kreće na put),
# koristimo SerializerMethodField za siguran dohvat brojeva dokumenata bez rušenja
brojevi_radnih_naloga = serializers.SerializerMethodField()
class Meta:
model = PutniNalog
# 🚀 USKLAĐENO s tvojim točnim poljima iz modela PutniNalog:
fields = [
'id',
'broj_naloga',
'datum_izdavanja',
'status',
'relacija',
'mjesto_odredista',
'pocetna_km',
'zavrsna_km',
'vozilo_registracija',
'brojevi_radnih_naloga'
]
def get_brojevi_radnih_naloga(self, obj):
# Dohvaćamo sve povezane radne naloge preko related_name='radni_nalozi'
nalozi = obj.radni_nalozi.all()
if nalozi.exists():
# Vraćamo npr. "RN-2026-0001, RN-2026-0002" ako ih ima više
return ", ".join([rn.broj_naloga for rn in nalozi if rn.broj_naloga])
return "Nema povezanih RN"
# --- GLAVNI OPERATIVNI SERIALIZER ZA TERMINAL SERVISERA ---
class ServiserTerminalSerializer(serializers.Serializer):
"""
Serializer koji objedinjuje sve podatke servisera i njegove dodijeljene resurse.
Ne nasljeđuje ModelSerializer jer serijalizira rječnik (dict) dobiven iz service sloja.
"""
id = serializers.IntegerField(source='serviser.id')
email = serializers.EmailField(source='serviser.email')
first_name = serializers.CharField(source='serviser.first_name')
last_name = serializers.CharField(source='serviser.last_name')
uloga = serializers.CharField(source='serviser.uloga')
# 🚀 Ugniježđeni podaci koji se pune kroz asocijativni rječnik iz services.py
radni_nalozi = ServiserRadniNalogSerializer(many=True)
putni_nalozi = ServiserPutniNalogSerializer(many=True)
vozila = ServiserVoziloSerializer(many=True)

View File

@@ -0,0 +1,37 @@
# users/services.py
from django.shortcuts import get_object_or_404
from django.core.exceptions import PermissionDenied
from .models import CustomUser
from operations.models import RadniNalog, PutniNalog
from fleet.models import Vozilo
def dohvati_operativne_podatke_servisera(user_id: int) -> dict:
"""
Prikuplja sve povezane resurse (radne naloge, putne naloge i vozila)
dodijeljene specifičnom serviseru.
"""
# 1. Dohvaćamo korisnika
korisnik = get_object_or_404(CustomUser, id=user_id)
# 2. Sigurnosni ček na razini poslovne logike
if getattr(korisnik, 'uloga', '').upper() != 'SERVISER':
raise PermissionDenied("Odabrani korisnik nema operativne ovlasti servisera.")
# 3. Dohvat povezanih entiteta prateći STVARNA polja iz tvoje baze podataka:
# Radni nalozi gdje je korisnik postavljen kao izvršitelj
radni_nalozi = RadniNalog.objects.filter(izvrsitelj=korisnik).select_related('klijent', 'stroj')
# 🚀 POPRAVAK: 'radni_nalozi__in' umjesto 'radni_nalog__in' (usklađivanje s related_name na RadniNalog modelu)
putni_nalozi = PutniNalog.objects.filter(radni_nalozi__in=radni_nalozi).select_related('vozilo')
# Izvlačimo jedinstvena vozila koja serviser vozi kroz svoje aktivne putne naloge
povezana_vozila_ids = putni_nalozi.values_list('vozilo_id', flat=True).distinct()
povezana_vozila = Vozilo.objects.filter(id__in=povezana_vozila_ids)
return {
"serviser": korisnik,
"radni_nalozi": radni_nalozi,
"putni_nalozi": putni_nalozi,
"vozila": povezana_vozila
}

View File

@@ -1,27 +1,97 @@
# users/views.py
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.conf import settings
from .models import CustomUser
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework import serializers
from .models import CustomUser
from django.core.exceptions import PermissionDenied
from .models import CustomUser
from .services import dohvati_operativne_podatke_servisera
from .serializers import ServiserTerminalSerializer
# 🚀 1. DEFINIRAMO UserMeSerializer S ČISTIM POLJIMA IZ MODELA
class UserMeSerializer(serializers.ModelSerializer):
uloga = serializers.SerializerMethodField()
is_serviser = serializers.SerializerMethodField()
class Meta:
model = CustomUser
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser']
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser', 'uloga']
def get_is_serviser(self, obj):
uloga_str = getattr(obj, 'uloga', '')
return str(uloga_str).upper().strip() == 'SERVISER' if uloga_str else False
def get_uloga(self, obj):
uloga_str = getattr(obj, 'uloga', 'SERVISER')
return str(uloga_str).upper().strip()
# 🚀 2. AKTIVNI VIEWSET S BACKEND OSIGURAČIMA
class UserViewSet(viewsets.ViewSet):
# Akcija 'me' bit će dostupna na /api/users/me/
@action(detail=False, methods=['get'])
authentication_classes = [JWTAuthentication]
# 🎯 POPRAVAK 1: Globalno zaključavamo ViewSet, samo ulogirani korisnici prolaze
permission_classes = [permissions.AllowAny]
@action(detail=False, methods=['get'], url_path='me')
def me(self, request):
# 1. Provjera je li korisnik stvarno logiran (ima validan token)
if not request.user.is_authenticated:
# Ova provjera ostaje kao oporavak u slučaju da JWT autentifikacija propusti prazan objekt
if not request.user or not request.user.is_authenticated:
return Response(
{"detail": "Niste prijavljeni. U DEBUG modu pristup je dozvoljen, ali nema podataka za AnonymousUser."},
status=status.HTTP_200_OK # Možeš vratiti 200 s praznim podacima ili 401
{
"detail": "Aktivna sesija nije pronađena. Pristup neautoriziran.",
"code": "token_not_valid"
},
status=status.HTTP_401_UNAUTHORIZED
)
# 2. Ako je logiran, normalno serijaliziraj
serializer = UserMeSerializer(request.user)
return Response(serializer.data)
try:
cisti_korisnik = CustomUser.objects.get(id=request.user.id)
serializer = UserMeSerializer(cisti_korisnik)
return Response(serializer.data, status=status.HTTP_200_OK)
except CustomUser.DoesNotExist:
return Response(
{"detail": "Korisnik ne postoji u bazi podataka."},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
print(f"Kritični krah unutar api/users/me: {str(e)}")
return Response(
{"detail": f"Interna greška poslužitelja: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=['get'], url_path='terminal')
def terminal_podaci(self, request, pk=None):
"""
Dohvaća sve operativne resurse za specifičnog servisera na ruti:
GET /api/users/<id>/terminal/
"""
# 🎯 POPRAVAK 2: BACKEND ZAŠTITA OD NJUŠKANJA URL-ova
trenutni_korisnik = request.user
trenutna_uloga = str(getattr(trenutni_korisnik, 'uloga', '')).upper().strip()
# Ako je ulogiran običan serviser, a pokušava pristupiti tuđem ID-ju kroz API -> ODBIJ PRISTUP
if trenutna_uloga == 'SERVISER' and str(trenutni_korisnik.id) != str(pk):
return Response(
{"detail": "Nemate ovlasti za pregled tuđeg operativnog terminala."},
status=status.HTTP_403_FORBIDDEN
)
try:
# 1. Okidamo biznis logiku iz services.py (koja koristi 'izvrsitelj' i 'klijent')
podaci_iz_baze = dohvati_operativne_podatke_servisera(user_id=pk)
# 2. Prosljeđujemo rječnik u objedinjeni serializer
serializer = ServiserTerminalSerializer(podaci_iz_baze)
return Response(serializer.data, status=status.HTTP_200_OK)
except PermissionDenied as pd_err:
return Response({"detail": str(pd_err)}, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
return Response(
{"detail": f"Greška pri obradi operativnih podataka: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -10,13 +10,19 @@ export default defineConfig({
}),
server: {
host: true,
host: '0.0.0.0',
port: 4321,
},
image: {
// Dodaj domene s kojih Astro smije povlačiti i optimizirati slike
domains: ['localhost', '127.0.0.1','v003-backend.captain.mitteworkspace.cloud'],
remotePatterns: [
{
protocol: 'https',
hostname: 'frontend-operativa.local.mitteworkspace.cloud',
port: '',
pathname: '/**',
},
],
},
build: {
@@ -24,11 +30,22 @@ export default defineConfig({
},
vite: {
build: {
// Smanji intenzitet optimizacije tijekom dev-a
minify: false,
cssMinify: false,
},
plugins: [tailwindcss()],
// KLJUČNO ZA TAILWIND v4 + NODE standalone SSR:
ssr: {
// Prisiljava Vite da uključi Tailwind v4 stilove u serverski bundle
noExternal: ['tailwindcss', '@tailwindcss/vite']
},
server: {
watch: {
ignored: ['**/node_modules/**', '**/dist/**'],
},
allowedHosts: ['.mitteworkspace.cloud']
}
}
});

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@astrojs/node": "^10.0.6",
"@tailwindcss/vite": "^4.2.4",
"astro": "^6.1.9",
"astro": "^6.3.7",
"flowbite": "^4.0.1",
"photoswipe": "^5.4.4",
"tailwindcss": "^4.2.4"
@@ -203,17 +203,6 @@
"node": ">= 20.12.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -224,16 +213,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -1161,40 +1140,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1205,270 +1156,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT",
"peer": true
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
@@ -2210,16 +1897,6 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2414,80 +2091,6 @@
"sharp": "^0.34.0"
}
},
"node_modules/astro/node_modules/vite": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -5304,40 +4907,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.132.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.2",
"@rolldown/binding-darwin-arm64": "1.0.2",
"@rolldown/binding-darwin-x64": "1.0.2",
"@rolldown/binding-freebsd-x64": "1.0.2",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
"@rolldown/binding-linux-arm64-musl": "1.0.2",
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
"@rolldown/binding-linux-x64-gnu": "1.0.2",
"@rolldown/binding-linux-x64-musl": "1.0.2",
"@rolldown/binding-openharmony-arm64": "1.0.2",
"@rolldown/binding-wasm32-wasi": "1.0.2",
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
"@rolldown/binding-win32-x64-msvc": "1.0.2"
}
},
"node_modules/rollup": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
@@ -5643,9 +5212,9 @@
}
},
"node_modules/tinyexec": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -6013,17 +5582,17 @@
}
},
"node_modules/vite": {
"version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.2",
"tinyglobby": "^0.2.16"
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
@@ -6039,10 +5608,9 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
@@ -6055,18 +5623,15 @@
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},

View File

@@ -2,6 +2,9 @@
"name": "poslovanje",
"type": "module",
"version": "0.0.1",
"overrides": {
"vite": "^7.0.0"
},
"engines": {
"node": ">=22.12.0"
},
@@ -10,12 +13,12 @@
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"start": "node ./dist/server/entry.mjs"
"start": "NODE_OPTIONS=--max-old-space-size=4096 npm run dev"
},
"dependencies": {
"@astrojs/node": "^10.0.6",
"@tailwindcss/vite": "^4.2.4",
"astro": "^6.1.9",
"astro": "^6.3.7",
"flowbite": "^4.0.1",
"photoswipe": "^5.4.4",
"tailwindcss": "^4.2.4"

9
001.FRONTEND/public/css/all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -54,7 +54,9 @@ import Button from "./Button.astro";
</div>
<script>
import { login } from "../lib/api";
import { login, fetchCurrentUser } from "../lib/api";
// 🚀 Ponovno uvozimo centraliziranu funkciju za rute
import { dohvatiRutuPoUlozi } from "../utils/auth";
const loginForm = document.querySelector('#login-form') as HTMLFormElement;
const btn = document.querySelector('#login-btn') as HTMLButtonElement;
@@ -64,10 +66,8 @@ import Button from "./Button.astro";
if (!btn) return;
// Pohranjujemo originalni sadržaj (ikonu + tekst)
const originalContent = btn.innerHTML;
// Vizualni feedback (Loading stanje)
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-3"></i> AUTENTIFIKACIJA...';
@@ -76,25 +76,32 @@ import Button from "./Button.astro";
const password = formData.get('password') as string;
try {
// 1. Prijava i pohrana JWT tokena
const result = await login(email, password);
if (result.success) {
// 2. Dohvat profila ulogiranog korisnika
const user = await fetchCurrentUser();
sessionStorage.setItem('pending_toast', JSON.stringify({
type: 'success',
message: 'Pristup odobren. Dobrodošli u sustav.'
}));
window.location.href = '/';
// 3. 🚀 PREUSMJERAVANJE PREKO POMOĆNE FUNKCIJE:
// Namjerno NE prosljeđujemo user.id jer želimo da serviser sleti na opću /operativa/serviseri stazu
const ciljanaRuta = dohvatiRutuPoUlozi(user?.uloga);
window.location.href = ciljanaRuta;
} else {
// Poziv tvog global-toast sustava
// @ts-ignore
window.showToast?.(result.error || "Pristup odbijen: Neispravni podaci.", "error");
// Vraćanje gumba u normalu
btn.disabled = false;
btn.innerHTML = originalContent;
}
} catch (err) {
// @ts-ignore
console.error("Login krah na klijentu:", err);
// @ts-ignore
window.showToast?.("Sustav nedostupan. Provjerite vezu.", "error");
btn.disabled = false;
btn.innerHTML = originalContent;

View File

@@ -1,59 +1,193 @@
---
// src/components/NaslovList.astro
// src/components/RadniNalogLista.astro
import NaslovList from "./NaslovList.astro";
interface Props {
naslov: string;
ukupno: number;
label1: string;
count1: number;
filter1: string;
label2: string;
count2: number;
filter2: string;
label3?: string;
count3?: number;
filter3?: string;
limit?: number;
naslov?: string;
prikaziNaslov?: boolean;
}
const { naslov, ukupno, label1, count1, filter1, label2, count2, filter2, label3, count3, filter3 } = Astro.props;
const {
limit = 0,
naslov = "Zadnje aktivnosti",
prikaziNaslov = true,
} = Astro.props;
---
<div class="flex flex-row justify-between items-center w-full min-h-[40px]">
<h2 id="filter-title" class="p-0 m-0 text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight italic leading-none">
{naslov}
</h2>
<div class="flex flex-wrap justify-end items-center gap-2 lg:gap-3">
<div
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border border-emerald-100 dark:border-emerald-800/50 cursor-pointer transition-all active:scale-95 hover:border-emerald-400"
data-filter={filter1}
>
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
<span class="text-[9px] lg:text-[10px] font-black uppercase text-emerald-700 dark:text-emerald-400 whitespace-nowrap">
{label1}: {count1}
</span>
</div>
<div class="space-y-6 group" id="radni-nalozi-sekcija" data-state="loading">
{prikaziNaslov && (
<NaslovList
naslov={naslov}
ukupno={0}
label1="Planirano"
count1={0}
filter1="planirano"
label2="U radu"
count2={0}
filter2="u_radu"
label3="Završeno"
count3={0}
filter3="zavrseno"
/>
)}
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
<div
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-100 dark:border-red-800/50 cursor-pointer transition-all active:scale-95 hover:border-red-400"
data-filter={filter2}
id="nalozi-loader"
class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse group-data-[state=loaded]:hidden group-data-[state=error]:hidden"
>
<div class="w-2 h-2 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-[9px] lg:text-[10px] font-black uppercase text-red-700 dark:text-red-400 whitespace-nowrap">
{label2}: {count2}
</span>
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija radnih naloga...
</div>
{label3 && count3 !== undefined && filter3 && (
<div
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-900/20 rounded-xl border border-gray-100 dark:border-gray-800/50 cursor-pointer transition-all active:scale-95 hover:border-gray-400"
data-filter={filter3}
>
<div class="w-2 h-2 rounded-full bg-gray-400"></div>
<span class="text-[9px] lg:text-[10px] font-black uppercase text-gray-700 dark:text-gray-400 whitespace-nowrap">
{label3}: {count3}
</span>
<div id="nalozi-list" class="space-y-0">
<div id="nalozi-placeholder" class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest group-data-[state=loaded]:hidden">
Nema zapisa za odabrani status
</div>
)}
</div>
</div>
</div>
</div>
<span id="rn-bridge" data-limit={limit} data-naslov={naslov}></span>
<script>
import { fetchCurrentUser, fetchDashboardData, fetchServiserTerminalData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
import { initFilters } from "../scripts/filters.js";
async function renderirajRadneNalogeKlijentski() {
const container = document.getElementById('nalozi-list');
const sekcija = document.getElementById('radni-nalozi-sekcija');
const bridge = document.getElementById('rn-bridge');
if (!container || !sekcija || !bridge) return;
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
if (!token) {
sekcija.setAttribute('data-state', 'error');
container.innerHTML = `
<div class="p-20 text-center flex flex-col items-center justify-center gap-3">
<div class="w-12 h-12 bg-red-600/10 text-red-600 rounded-xl flex items-center justify-center text-lg mb-2">
<i class="fa-solid fa-shield-halved"></i>
</div>
<h4 class="text-xs font-black uppercase tracking-widest text-red-600">Pristup odbijen</h4>
<p class="text-[10px] text-gray-400 font-bold uppercase tracking-wider italic">Niste autorizirani za pregled operativnih naloga flote.</p>
</div>
`;
return;
}
const limit = parseInt(bridge.getAttribute('data-limit') || '0');
const originalNaslov = bridge.getAttribute('data-naslov') || 'Zadnje aktivnosti';
const urlParams = new URLSearchParams(window.location.search);
const statusFilter = urlParams.get('status')?.toLowerCase();
try {
sekcija.setAttribute('data-state', 'loading');
const user = await fetchCurrentUser();
const ulogaGore = user?.uloga ? String(user.uloga).toUpperCase().trim() : '';
let nalozi = [];
if (ulogaGore === 'SERVISER' && user?.id) {
const terminalData = await fetchServiserTerminalData(user.id);
nalozi = terminalData?.radni_nalozi || [];
} else {
const data = await fetchDashboardData();
nalozi = data?.nalozi || [];
}
// 3. Proračun statistike na klijentskoj strani
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
const zavrsenoCount = nalozi.filter(n => ['zavrsen', 'završeno', 'gotovo', 'zavrseno'].includes(n.status?.toLowerCase())).length;
// 4. 🚀 USKLAĐENO HVAĆANJE I FILTRIRANJE PREMA TVOM data-filter PROPSU:
const card1 = sekcija.querySelector('[data-filter="planirano"]');
if (card1) {
const span = card1.querySelector('span');
if (span) span.textContent = `Planirano: ${planiranoCount}`;
if (planiranoCount > 0) card1.classList.remove('hidden'); else card1.classList.add('hidden');
}
const card2 = sekcija.querySelector('[data-filter="u_radu"]');
if (card2) {
const span = card2.querySelector('span');
if (span) span.textContent = `U radu: ${uRaduCount}`;
if (uRaduCount > 0) card2.classList.remove('hidden'); else card2.classList.add('hidden');
}
const card3 = sekcija.querySelector('[data-filter="zavrseno"]');
if (card3) {
const span = card3.querySelector('span');
if (span) span.textContent = `Završeno: ${zavrsenoCount}`;
if (zavrsenoCount > 0) card3.classList.remove('hidden'); else card3.classList.add('hidden');
}
// 5. Filtriranje i limitiranje niza prema klijentskom stanju URL-a
let filtriraniNalozi = statusFilter ? nalozi.filter(n => n.status?.toLowerCase() === statusFilter) : nalozi;
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
const glavniNaslov = sekcija.querySelector('h2');
if (glavniNaslov) {
glavniNaslov.innerText = statusFilter ? `Filtrirano: ${formatStatus(statusFilter)}` : originalNaslov;
}
sekcija.setAttribute('data-state', 'loaded');
// 7. Generiranje HTML kartica radnih naloga unutar tijela
if (prikazaniNalozi.length === 0) {
container.innerHTML = `<div class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest">Nema zapisa za odabrani status</div>`;
return;
}
container.innerHTML = prikazaniNalozi.map((n) => {
const statusMalo = n.status?.toLowerCase();
const statusKlasa = getStatusColorClass(statusMalo);
const ikona = statusMalo === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice';
const klijentPrikaz = n.kupac_naziv || n.klijent_naziv || 'Nepoznat klijent';
const strojPrikaz = n.stroj_naziv || 'Bez stroja';
const voziloPrikaz = n.vozilo_naziv || 'Bez vozila';
return `
<a href="/operativa/radni-nalozi/${n.id}" class="flex flex-wrap items-center justify-between p-6 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700/50 last:border-none hover:bg-gray-50/50 dark:hover:bg-gray-900/20 transition-all gap-4 group">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-2xl flex items-center justify-center text-lg shadow-sm group-hover:scale-105 transition-transform">
<i class="fa-solid ${ikona}"></i>
</div>
<div>
<h4 class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">#${n.broj_naloga}</h4>
<p class="text-[11px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">${formatStatus(statusMalo)}</p>
<p class="text-[10px] text-gray-400/70 font-medium mt-1">
${klijentPrikaz} | ${voziloPrikaz} | ${strojPrikaz}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-black px-3 py-1.5 rounded-full border uppercase tracking-wider ${statusKlasa}">
${formatStatus(statusMalo)}
</span>
<div class="w-9 h-9 flex items-center justify-center bg-gray-50 dark:bg-gray-700/50 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 rounded-xl border border-gray-100 dark:border-gray-600 shadow-sm transition-all">
<i class="fa-solid fa-chevron-right text-xs"></i>
</div>
</div>
</a>
`;
}).join('');
initFilters();
} catch (err) {
console.error("Kritična greška u klijentskom renderu radnih naloga:", err);
sekcija.setAttribute('data-state', 'error');
container.innerHTML = '<div class="p-10 text-center text-red-500 font-bold uppercase text-xs">Greška pri sinkronizaciji operativnih podataka.</div>';
}
}
renderirajRadneNalogeKlijentski();
document.addEventListener('astro:after-swap', renderirajRadneNalogeKlijentski);
</script>

View File

@@ -1,6 +1,7 @@
---
// src/components/Navbar.astro
import site from "../data/site.json";
import Button from "./Button.astro";
const currentPath = Astro.url.pathname;
---
@@ -9,7 +10,6 @@ const currentPath = Astro.url.pathname;
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-20 items-center">
<!-- LOGO SEKCIJA -->
<div class="flex items-center">
<a href="/" class="flex items-center gap-3 group">
<div class="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:rotate-6 transition-transform duration-300">
@@ -21,87 +21,159 @@ const currentPath = Astro.url.pathname;
</a>
</div>
<div id="nav-auth-section" class="hidden flex items-center gap-4 group/auth" data-state="loading">
<div class="hidden group-data-[state=anonymous]/auth:block">
<Button
id="nav-login-btn"
href="/login"
variant="none"
class="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-2xl text-xs font-black uppercase tracking-wider shadow-lg shadow-blue-500/20 active:scale-95 transition-all border border-blue-500 cursor-pointer italic"
>
<i class="fa-solid fa-right-to-bracket"></i> Prijava
</Button>
</div>
<div class="hidden group-data-[state=authenticated]/auth:block">
<Button
id="mobile-menu-button"
variant="none"
class="w-12 h-12 flex items-center justify-center rounded-2xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 active:scale-90 transition-all border border-gray-100 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50"
>
<i class="fa-solid fa-bars-staggered text-xl"></i>
</Button>
</div>
<!-- MOBILE MENU GUMB -->
<div class="md:hidden flex items-center">
<button
id="mobile-menu-button"
type="button"
class="w-12 h-12 flex items-center justify-center rounded-2xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 active:scale-90 transition-all border border-gray-100 dark:border-gray-700"
>
<i class="fa-solid fa-bars-staggered text-xl"></i>
</button>
</div>
</div>
</div>
<!-- LEBDEĆI MOBILNI IZBORNIK (Transparentan) -->
<div
id="mobile-menu"
class="hidden absolute top-[calc(100%+0.5rem)] left-4 right-4
class="hidden absolute top-[calc(100%+0.5rem)] md:left-auto left-4 right-4 md:right-8 md:w-80
bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl
border border-white/20 dark:border-gray-800/50
p-4 rounded-[2.5rem] shadow-2xl z-[100]
p-4 rounded-[2.5rem] shadow-2xl shadow-blue-500/5 z-[100]
transition-all animate-in fade-in slide-in-from-top-4"
>
<div class="flex flex-col gap-2">
{site.navigation.map((item) => (
<a
href={item.url}
class="flex items-center justify-between px-6 py-4 rounded-[1.8rem] font-black text-[11px] uppercase tracking-widest text-gray-600 dark:text-gray-300 hover:bg-blue-600 hover:text-white transition-all duration-300 group"
{site.navigation.map((item) => {
// Provjera je li trenutna putanja aktivna (uzima u obzir i potencijalne dinamičke ID pod-rute)
const isCurrent = currentPath === item.url || (item.url === '/operativa/serviseri' && currentPath.includes('/operativa/serviseri'));
return (
<a
href={item.url}
data-nav-link={item.url}
class={`flex items-center justify-between px-6 py-4 rounded-[1.8rem] font-black text-[11px] uppercase tracking-widest transition-all duration-300 group italic ${
isCurrent
? "bg-blue-600 text-white shadow-lg shadow-blue-500/20"
: "text-gray-600 dark:text-gray-300 hover:bg-blue-600 hover:text-white"
}`}
>
{item.name}
<i class="fa-solid fa-chevron-right text-[10px] opacity-30 group-hover:translate-x-1 group-hover:opacity-100 transition-all"></i>
</a>
);
})}
<div class="w-full mt-1 mb-2">
<Button
href="/operativa/radni-nalozi/novi"
variant="none"
class="w-full bg-blue-600 text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center shadow-lg shadow-blue-500/40 active:scale-95 transition-all block italic"
>
{item.name}
<i class="fa-solid fa-chevron-right text-[10px] opacity-30 group-hover:translate-x-1 group-hover:opacity-100 transition-all"></i>
</a>
))}
<i class="fa-solid fa-plus mr-2"></i> Novi nalog
</Button>
</div>
<hr class="my-2 border-gray-100/50 dark:border-gray-800/50" />
<a href="/operativa/radni-nalozi/novi" class="w-full bg-blue-600 text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center shadow-lg shadow-blue-500/40 active:scale-95 transition-all">
<i class="fa-solid fa-plus mr-2"></i> Novi nalog
</a>
<div class="w-full">
<Button
id="logout-mobile"
variant="none"
class="w-full bg-red-600/10 text-red-600 hover:bg-red-600 hover:text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center active:scale-95 transition-all border border-red-100/30 dark:border-red-900/30 cursor-pointer block italic"
>
<i class="fa-solid fa-right-from-bracket mr-2"></i> Odjava iz sustava
</Button>
</div>
</div>
</div>
</nav>
<script>
function setupNavbar() {
import { logout, fetchCurrentUser } from "../lib/api";
async function setupNavbar() {
const btn = document.getElementById('mobile-menu-button');
const menu = document.getElementById('mobile-menu');
const authSection = document.getElementById('nav-auth-section');
const logoutMobile = document.getElementById('logout-mobile');
if (btn && menu) {
// Re-inicijalizacija gumba za Astro View Transitions
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
btn.parentNode?.replaceChild(newBtn, btn);
if (!authSection) return;
newBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
const stanjeAutentifikacije = token ? 'authenticated' : 'anonymous';
// Zatvori menu na klik izvan njega
document.addEventListener('click', (e) => {
if (!menu.contains(e.target as Node) && !newBtn.contains(e.target as Node)) {
menu.classList.add('hidden');
// 1. Postavljamo stanje u DOM (odmah prebacuje vidljivost gumbova Prijava / Menu)
authSection.setAttribute('data-state', stanjeAutentifikacije);
authSection.classList.remove('hidden');
authSection.classList.add('flex');
// 2. Dinamičko popravljanje URL putanje za servisera ako imamo token i menu elemente
if (token && menu) {
let userProfil = null;
try {
// 🚀 OBRANA OD KRAHA: Omotavamo hvat u zaseban try-catch kako greška s API-ja ne bi srušila cijeli navbar
userProfil = await fetchCurrentUser();
} catch (apiErr) {
console.warn("Navbar nije uspio dohvatiti trenutnog korisnika s API-ja:", apiErr);
}
if (userProfil && userProfil.id) {
const serviserLink = menu.querySelector('[data-nav-link="/operativa/serviseri"]') as HTMLAnchorElement;
if (serviserLink) {
serviserLink.href = `/operativa/serviseri/${userProfil.id}`;
// Popravak aktivne klase ukoliko smo točno na toj dinamičkoj ruti
if (window.location.pathname === `/operativa/serviseri/${userProfil.id}`) {
serviserLink.classList.add('bg-blue-600', 'text-white', 'shadow-lg', 'shadow-blue-500/20');
serviserLink.classList.remove('text-gray-600', 'dark:text-gray-300');
}
}
});
// Zatvori nakon odabira linka
menu.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => menu.classList.add('hidden'));
});
}
}
if (!btn || !menu) return;
// Resetiranje event listenera kloniranjem (sprječava duplanje na View Transitions okidanju)
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
btn.parentNode?.replaceChild(newBtn, btn);
newBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!menu.contains(e.target as Node) && !newBtn.contains(e.target as Node)) {
menu.classList.add('hidden');
}
});
menu.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => menu.classList.add('hidden'));
});
logoutMobile?.addEventListener('click', () => {
logout();
});
}
// Logout funkcija
const logout = () => {
sessionStorage.removeItem('user_session');
window.location.href = '/login';
};
document.getElementById('logout-btn')?.addEventListener('click', logout);
document.getElementById('logout-mobile')?.addEventListener('click', logout);
// Okidanje na inicijalno učitavanje i Astro after-swap
setupNavbar();
document.addEventListener('astro:after-swap', setupNavbar);
</script>

View File

@@ -0,0 +1,161 @@
---
// src/components/PutniNalogLista.astro
import NaslovList from "./NaslovList.astro";
interface Props {
naslov?: string;
prikaziNaslov?: boolean;
voziloId?: string | number;
}
const {
naslov = "Putni nalozi i rute",
prikaziNaslov = true,
voziloId = null,
} = Astro.props;
---
<div class="space-y-6" id="putni-nalozi-sekcija">
{prikaziNaslov && (
<NaslovList
naslov={naslov}
ukupno={0}
label1="Aktivne rute" count1={0} filter1="aktivan"
label2="Završeni" count2={0} filter2="zavrsen"
/>
)}
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
<div id="pn-loader" class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse">
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija putnih naloga...
</div>
<div id="putni-nalozi-list" class="space-y-0"></div>
</div>
</div>
<span id="pn-bridge" data-vozilo-id={voziloId} data-naslov={naslov}></span>
<script>
import { fetchPutniNaloziData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
import { initFilters } from "../scripts/filters.js";
async function renderirajPutneNalogeKlijentski() {
const bridge = document.getElementById('pn-bridge');
const container = document.getElementById('putni-nalozi-list');
const loader = document.getElementById('pn-loader');
if (!bridge || !container) return;
const voziloId = bridge.getAttribute('data-vozilo-id');
const originalNaslov = bridge.getAttribute('data-naslov') || 'Putni nalozi i rute';
// Dohvat aktivnog filtera iz URL-a na klijentu (?status=aktivan)
const urlParams = new URLSearchParams(window.location.search);
const statusFilter = urlParams.get('status')?.toLowerCase();
try {
// 1. Dohvat s API-ja (Sada sigurno čita localStorage token u pregledniku)
const data = await fetchPutniNaloziData();
// Prilagodba ako API vraća omotan objekt ili čisti niz
const sviPutniNalozi = Array.isArray(data) ? data : (data?.results || []);
// 2. STROGO FILTRIRANJE PREMA ID-JU VOZILA (Na razini podataka)
let filtriraniNalozi = sviPutniNalozi;
if (voziloId) {
const ciljaniId = parseInt(voziloId);
filtriraniNalozi = sviPutniNalozi.filter(pn => {
const vId = pn.vozilo?.id || pn.vozilo || pn.vozilo_id;
return parseInt(vId) === ciljaniId;
});
}
// 3. Izračun statistike na klijentskoj strani (isključivo za ovo vozilo ako je ID prisutan)
const aktivniCount = filtriraniNalozi.filter(pn => pn.status?.toLowerCase() === 'aktivan').length;
const zavrseniCount = filtriraniNalozi.filter(pn => ['zavrsen', 'završeno'].includes(pn.status?.toLowerCase())).length;
// Opcionalno dodatno filtriranje po statusu s gornjih gumbića
if (statusFilter) {
filtriraniNalozi = filtriraniNalozi.filter(pn => pn.status?.toLowerCase() === statusFilter);
}
// 4. Dinamičko ažuriranje brojača i naslova u DOM-u (NaslovList)
const sekcija = document.getElementById('putni-nalozi-sekcija');
if (sekcija) {
const glavniNaslov = sekcija.querySelector('h2');
if (glavniNaslov) {
glavniNaslov.innerText = statusFilter ? `Filtrirano: ${formatStatus(statusFilter)}` : originalNaslov;
}
const ukupnoBadge = sekcija.querySelector('h2 + span, .ukupno-badge');
if (ukupnoBadge) {
ukupnoBadge.textContent = filtriraniNalozi.length.toString();
}
const aktivniBadge = sekcija.querySelector('[data-filter="aktivan"] span, [filter-type="aktivan"]');
if (aktivniBadge) aktivniBadge.textContent = aktivniCount.toString();
const zavrseniBadge = sekcija.querySelector('[data-filter="zavrsen"] span, [filter-type="zavrsen"]');
if (zavrseniBadge) zavrseniBadge.textContent = zavrseniCount.toString();
}
// Sakrij loader
if (loader) loader.classList.add('hidden');
// 5. Generiranje HTML-a (Preslikana točna struktura tvog GenericKarticaItem)
if (filtriraniNalozi.length === 0) {
container.innerHTML = `
<div class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest">
Nema evidentiranih putnih naloga
</div>`;
return;
}
container.innerHTML = filtriraniNalozi.map((pn) => {
const statusMalo = pn.status?.toLowerCase();
const statusKlasa = getStatusColorClass(statusMalo);
const relacija = pn.relacija || pn.mjesto_odredista || 'Terenska lokacija';
const vozilo = pn.vozilo_naziv || 'Dodijeljeno vozilo';
const izvrsitelj = pn.korisnik_ime || 'Vozač tehničar';
const pocetniKm = pn.pocetna_km?.toLocaleString('hr-HR') || 0;
return `
<a href="/operativa/putni-nalozi/${pn.id}" class="flex flex-wrap items-center justify-between p-6 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700/50 last:border-none hover:bg-gray-50/50 dark:hover:bg-gray-900/20 transition-all gap-4 group">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-2xl flex items-center justify-center text-lg shadow-sm group-hover:scale-105 transition-transform">
<i class="fa-solid fa-route"></i>
</div>
<div>
<h4 class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">#${pn.broj_naloga}</h4>
<p class="text-[11px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">${relacija}</p>
<p class="text-[10px] text-gray-400/70 font-medium mt-1">
Vozilo: ${vozilo} | Izvršitelj: ${izvrsitelj} | Početno: ${pocetniKm} KM
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-black px-3 py-1.5 rounded-full border uppercase tracking-wider ${statusKlasa}">
${pn.status_prikaz || formatStatus(statusMalo)}
</span>
<div class="w-9 h-9 flex items-center justify-center bg-gray-50 dark:bg-gray-700/50 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 rounded-xl border border-gray-100 dark:border-gray-600 shadow-sm transition-all">
<i class="fa-solid fa-chevron-right text-xs"></i>
</div>
</div>
</div>
`;
}).join('');
// Pokretanje klijentskih filtera nakon što se DOM uspješno izgradi
initFilters();
} catch (err) {
console.error("Kritična greška u klijentskom renderu putnih naloga:", err);
if (container) container.innerHTML = '<div class="p-10 text-center text-red-500 font-bold uppercase text-xs">Greška pri sinkronizaciji putnih naloga.</div>';
}
}
// Izvršavanje pri učitavanju i re-inicijalizacija kod Astro View Transitions prebacivanja rute
renderirajPutneNalogeKlijentski();
document.addEventListener('astro:after-swap', renderirajPutneNalogeKlijentski);
</script>

View File

@@ -1,9 +1,6 @@
---
// src/components/RadniNalogLista.astro
import { fetchDashboardData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
import NaslovList from "./NaslovList.astro";
import GenericKarticaItem from "./GenericKarticaItem.astro";
interface Props {
limit?: number;
@@ -16,80 +13,190 @@ const {
naslov = "Zadnje aktivnosti",
prikaziNaslov = true,
} = Astro.props;
// 1. Dohvat filtera iz URL-a (pretvaramo u mala slova radi sigurnosti)
const statusFilter = Astro.url.searchParams.get('status')?.toLowerCase();
// 2. Dohvat podataka s API-ja
const { nalozi = [] } = await fetchDashboardData();
// 3. Logika filtriranja (Serverska strana - tolerantna na velika/mala slova)
let filtriraniNalozi = nalozi;
if (statusFilter) {
filtriraniNalozi = nalozi.filter(n => n.status?.toLowerCase() === statusFilter);
}
// 4. POPRAVLJENO: Izračun brojeva uz obvezno pretvaranje u mala slova (.toLowerCase())
// Na ovaj način 'PLANIRANO' s backenda sigurno postaje 'planirano' u JS-u
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
// 5. Primjena limita na prikaz (npr. top 5 nalozi)
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
// Dinamički naslov ovisno o filteru
const prikazaniNaslov = statusFilter
? `Filtrirano: ${formatStatus(statusFilter)}`
: naslov;
---
<div class="space-y-6">
<div class="space-y-6 group" id="radni-nalozi-sekcija" data-state="loading">
{prikaziNaslov && (
<NaslovList
naslov={prikazaniNaslov}
ukupno={filtriraniNalozi.length}
label1="Planirano"
count1={planiranoCount} // Sada će ispravno prikazati stvarni broj
filter1="planirano"
label2="U radu"
count2={uRaduCount} // Sada će ispravno prikazati stvarni broj
filter2="u_radu"
naslov={naslov}
sinkronizirano={false} label1="Planirano" count1={0} filter1="planirano"
label2="U radu" count2={0} filter2="u_radu"
/>
)}
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
<div id="nalozi-list">
{prikazaniNalozi.length > 0 ? (
prikazaniNalozi.map((n) => (
<GenericKarticaItem
href={`/operativa/radni-nalozi/${n.id}`}
status={n.status?.toLowerCase()} // Šaljemo ujednačeni lowercase u podkomponente
statusBojaClass={getStatusColorClass(n.status?.toLowerCase())}
ikona={n.status?.toLowerCase() === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice'}
naslov={`#${n.broj_naloga}`}
subNaslov={formatStatus(n.status?.toLowerCase())}
metaTekst={`${n.klijent_naziv || 'Nepoznat klijent'} | ${n.vozilo_naziv || 'Bez vozila'} | ${n.stroj_naziv || 'Bez stroja'}`}
statusPrikaz={formatStatus(n.status?.toLowerCase())}
bojaTeme={n.boja_teme || 'blue'}
/>
))
) : (
<div class="p-20 text-center uppercase font-black opacity-20 italic">
Nema zapisa za odabrani status
</div>
)}
<div
id="nalozi-loader"
class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse group-data-[state=loaded]:hidden group-data-[state=error]:hidden"
>
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija radnih naloga...
</div>
<div id="nalozi-list" class="space-y-0">
<div id="nalozi-placeholder" class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest group-data-[state=loaded]:hidden">
Nema zapisa za odabrani status
</div>
</div>
</div>
</div>
<script>
// Ponovno inicijaliziramo klijentsku skriptu za klikove na kartice filtera
import { initFilters } from "../scripts/filters.js";
// Prvo pokretanje
initFilters();
<span id="rn-bridge" data-limit={limit} data-naslov={naslov}></span>
// Podrška za Astro View Transitions
document.addEventListener('astro:after-swap', initFilters);
<script>
import { fetchDashboardData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
import { initFilters } from "../scripts/filters.js";
async function renderirajRadneNalogeKlijentski() {
const container = document.getElementById('nalozi-list');
const sekcija = document.getElementById('radni-nalozi-sekcija');
const bridge = document.getElementById('rn-bridge');
if (!container || !sekcija || !bridge) return;
// 1. KLIJENTSKI OSIGURAČ: Provjera autorizacije prije API poziva preko ključa iz tvojeg api.js
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
if (!token) {
// Ako nema tokena, odmah zaključaj listu i baci sigurnosni Error izgled
sekcija.setAttribute('data-state', 'error');
container.innerHTML = `
<div class="p-20 text-center flex flex-col items-center justify-center gap-3">
<div class="w-12 h-12 bg-red-600/10 text-red-600 rounded-xl flex items-center justify-center text-lg mb-2">
<i class="fa-solid fa-shield-halved"></i>
</div>
<h4 class="text-xs font-black uppercase tracking-widest text-red-600">Pristup odbijen</h4>
<p class="text-[10px] text-gray-400 font-bold uppercase tracking-wider italic">Niste autorizirani za pregled operativnih naloga flote.</p>
</div>
`;
return;
}
const limit = parseInt(bridge.getAttribute('data-limit') || '0');
const originalNaslov = bridge.getAttribute('data-naslov') || 'Zadnje aktivnosti';
const urlParams = new URLSearchParams(window.location.search);
const statusFilter = urlParams.get('status')?.toLowerCase();
try {
// Svaki put kad klijent pokrene funkciju, osiguravamo loading stanje
sekcija.setAttribute('data-state', 'loading');
// 2. Dohvat podataka (Šalje fetch zahtjev s ispravnim Authorization zaglavljem)
const data = await fetchDashboardData();
const nalozi = data?.nalozi || [];
// 3. Proračun statistike na klijentskoj strani
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
const zavrsenoCount = nalozi.filter(n => ['zavrsen', 'završeno', 'gotovo', 'zavrseno'].includes(n.status?.toLowerCase())).length;
// 4. 🛠️ POPRAVLJENO: Upisivanje vrijednosti i inteligentno sakrivanje samo ako je brojač točno 0
const card1 = sekcija.querySelector('#card-filter1');
if (card1) {
const val = card1.querySelector('.count-value');
if (val) val.textContent = planiranoCount.toString();
if (planiranoCount > 0) {
card1.classList.remove('hidden');
} else {
card1.classList.add('hidden');
}
}
const card2 = sekcija.querySelector('#card-filter2');
if (card2) {
const val = card2.querySelector('.count-value');
if (val) val.textContent = uRaduCount.toString();
if (uRaduCount > 0) {
card2.classList.remove('hidden');
} else {
card2.classList.add('hidden');
}
}
const card3 = sekcija.querySelector('#card-filter3');
if (card3) {
const val = card3.querySelector('.count-value');
if (val) val.textContent = zavrsenoCount.toString();
if (zavrsenoCount > 0) {
card3.classList.remove('hidden');
} else {
card3.classList.add('hidden');
}
}
// 5. Filtriranje i limitiranje niza prema klijentskom stanju URL-a
let filtriraniNalozi = statusFilter ? nalozi.filter(n => n.status?.toLowerCase() === statusFilter) : nalozi;
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
// Ažuriranje teksta glavnog naslova ovisno o aktivnom filteru
const glavniNaslov = sekcija.querySelector('h2');
if (glavniNaslov) {
glavniNaslov.innerText = statusFilter ? `Filtrirano: ${formatStatus(statusFilter)}` : originalNaslov;
}
// 6. 🚀 SINKRONIZACIJA PROPSA: Otključavamo kontejner unutar NaslovList.astro prebacivanjem u 'loaded'
const statCardsContainer = sekcija.querySelector('#stat-cards-container');
if (statCardsContainer) {
statCardsContainer.setAttribute('data-state', 'loaded');
}
// Isključivanje općeg loading stanja s krovne sekcije
sekcija.setAttribute('data-state', 'loaded');
// 7. Generiranje HTML kartica radnih naloga unutar tijela
if (prikazaniNalozi.length === 0) {
container.innerHTML = `<div class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest">Nema zapisa za odabrani status</div>`;
return;
}
container.innerHTML = prikazaniNalozi.map((n) => {
const statusMalo = n.status?.toLowerCase();
const statusKlasa = getStatusColorClass(statusMalo);
const ikona = statusMalo === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice';
return `
<a href="/operativa/radni-nalozi/${n.id}" class="flex flex-wrap items-center justify-between p-6 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700/50 last:border-none hover:bg-gray-50/50 dark:hover:bg-gray-900/20 transition-all gap-4 group">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-2xl flex items-center justify-center text-lg shadow-sm group-hover:scale-105 transition-transform">
<i class="fa-solid ${ikona}"></i>
</div>
<div>
<h4 class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">#${n.broj_naloga}</h4>
<p class="text-[11px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">${formatStatus(statusMalo)}</p>
<p class="text-[10px] text-gray-400/70 font-medium mt-1">
${n.klijent_naziv || 'Nepoznat klijent'} | ${n.vozilo_naziv || 'Bez vozila'} | ${n.stroj_naziv || 'Bez stroja'}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-black px-3 py-1.5 rounded-full border uppercase tracking-wider ${statusKlasa}">
${formatStatus(statusMalo)}
</span>
<div class="w-9 h-9 flex items-center justify-center bg-gray-50 dark:bg-gray-700/50 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 rounded-xl border border-gray-100 dark:border-gray-600 shadow-sm transition-all">
<i class="fa-solid fa-chevron-right text-xs"></i>
</div>
</div>
</div>
`;
}).join('');
// Aktiviraj klijentske klikove na kartice filtera
initFilters();
} catch (err) {
console.error("Kritična greška u klijentskom renderu radnih naloga:", err);
sekcija.setAttribute('data-state', 'error');
container.innerHTML = '<div class="p-10 text-center text-red-500 font-bold uppercase text-xs">Greška pri sinkronizaciji operativnih podataka.</div>';
}
}
// Prvo pokretanje pri učitavanju stranice
renderirajRadneNalogeKlijentski();
// Ponovno pokretanje prilikom SPA navigacije kroz aplikaciju
document.addEventListener('astro:after-swap', renderirajRadneNalogeKlijentski);
</script>

View File

@@ -0,0 +1,242 @@
---
// src/components/ServiserTerminal.astro
import Button from "./Button.astro";
import Toast from "./Toast.astro";
---
<div class="space-y-6 group" id="serviser-terminal-sekcija" data-state="loading">
<div class="group-data-[state=loading]:flex hidden items-center gap-3 text-xs font-black text-gray-400 uppercase tracking-widest animate-pulse bg-white dark:bg-gray-800 p-8 rounded-[2rem] border border-gray-100 dark:border-gray-700/50 shadow-md">
<i class="fa-solid fa-circle-notch animate-spin text-blue-600 text-base"></i> Sinkronizacija radnog zadatka s baze...
</div>
<div id="terminal-prazno-stanje" class="hidden p-20 text-center bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-[3rem] uppercase font-black opacity-30 italic text-xs tracking-widest shadow-2xl shadow-blue-500/5">
Trenutno nemate dodijeljenih aktivnih radnih naloga na terenu.
</div>
<div id="terminal-radni-prostor" class="hidden group-data-[state=loaded]:grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 p-8 shadow-xl shadow-blue-500/5">
<div class="flex justify-between items-start gap-4 border-b border-gray-100 dark:border-gray-700/50 pb-4 mb-6">
<div>
<h3 id="rn-broj" class="text-xl font-black uppercase tracking-tight text-gray-900 dark:text-white">#RN-UČITAVANJE</h3>
<p id="rn-klijent" class="text-xs font-bold text-gray-400 mt-0.5 uppercase tracking-wider">Klijent: ...</p>
</div>
<span id="rn-status-znacka" class="text-[10px] font-black px-4 py-2 rounded-full border uppercase tracking-wider bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/20 dark:text-amber-400 dark:border-amber-800/50">
U RADU
</span>
</div>
<div class="space-y-4">
<div>
<h4 class="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 mb-1 italic">Prijavljeni kvar / Pogonski stroj flote</h4>
<div id="rn-stroj-detalji" class="p-4 bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 rounded-2xl text-xs font-bold text-gray-700 dark:text-gray-300 shadow-inner">
Učitavanje podataka o stroju...
</div>
</div>
<div class="pt-4">
<Button id="btn-zatvori-rn" variant="none" class="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-2xl font-black uppercase text-xs tracking-[0.2em] shadow-lg shadow-emerald-500/10 active:scale-95 transition-all">
<i class="fa-solid fa-check-double mr-2"></i> Dovrši servis i aktiviraj stroj
</Button>
</div>
</div>
</div>
</div>
<div class="space-y-6">
<div id="putni-nalog-kartica" class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 p-8 shadow-xl shadow-blue-500/5 flex flex-col justify-between min-h-[300px]">
<div>
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-xl flex items-center justify-center text-md shadow-sm">
<i class="fa-solid fa-truck-ramp-box"></i>
</div>
<div>
<h4 id="pn-broj" class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">PN-UČITAVANJE</h4>
<p id="pn-vozilo" class="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Vozilo: ...</p>
</div>
</div>
<hr class="my-4 border-gray-100 dark:border-gray-700/50" />
<div class="space-y-2">
<label for="zavrsna-km" class="block text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 ml-2 italic">Završna KM Vozila</label>
<input
type="number"
id="zavrsna-km"
class="w-full px-4 py-3.5 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-blue-600 rounded-xl outline-none text-xs font-bold text-gray-900 dark:text-white shadow-inner"
placeholder="Unesi stanje brojača (km)..."
/>
</div>
</div>
<div class="pt-6">
<Button id="btn-zatvori-pn" variant="danger" class="w-full py-3.5 rounded-2xl text-[10px] tracking-wider italic">
<i class="fa-solid fa-flag-checkered mr-2"></i> Zaključaj putni list
</Button>
</div>
</div>
</div>
</div>
<Toast />
</div>
<script>
import { fetchRadniNalozi, patchNalog } from "../lib/api";
const API_BASE = import.meta.env.PUBLIC_API_URL;
let aktivniNalog: any = null;
async function inicijalizirajTerminalServisera() {
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
const sekcija = document.getElementById('serviser-terminal-sekcija');
const radniProstor = document.getElementById('terminal-radni-prostor');
const praznoStanje = document.getElementById('terminal-prazno-stanje');
if (!token || !sekcija || !radniProstor || !praznoStanje) return;
try {
sekcija.setAttribute('data-state', 'loading');
// 🛠️ OPTIMIZIRANO: Više ne dohvaćamo korisnika klijentski jer WelcomeHeader to rješava na serveru.
// Izravno dohvaćamo radne naloge kroz postojeće REST rute
const naloziData = await fetchRadniNalozi();
// Filtriramo naloge koji su u radu
const mojiNalozi = naloziData.filter((n: any) => n.status?.toLowerCase() === 'u_radu');
if (mojiNalozi.length === 0) {
sekcija.setAttribute('data-state', 'loaded');
radniProstor.classList.add('hidden');
praznoStanje.classList.remove('hidden');
return;
}
aktivniNalog = mojiNalozi[0];
praznoStanje.classList.add('hidden');
radniProstor.classList.remove('hidden');
// Injekcija podataka u DOM
document.getElementById('rn-broj')!.textContent = `#${aktivniNalog.broj_naloga}`;
document.getElementById('rn-klijent')!.textContent = `Klijent: ${aktivniNalog.klijent_naziv || 'Opći klijent'}`;
document.getElementById('rn-stroj-detalji')!.innerHTML = `
<div class="space-y-1">
<p><span class="opacity-50 font-black">STROJ U KVARU:</span> ${aktivniNalog.stroj_naziv || 'Nije specificirano'}</p>
<p><span class="opacity-50 font-black">DODIJELJENO VOZILO:</span> ${aktivniNalog.vozilo_naziv || 'Bez servisnog vozila'}</p>
</div>
`;
// Provjera postojanja povezanog putnog naloga
const putniKartica = document.getElementById('putni-nalog-kartica');
if (putniKartica) {
if (aktivniNalog.putni_nalog) {
putniKartica.classList.remove('hidden');
document.getElementById('pn-broj')!.textContent = `PN-${aktivniNalog.putni_nalog}`;
document.getElementById('pn-vozilo')!.textContent = `Vozilo: ${aktivniNalog.vozilo_naziv || 'Servisni kombi'}`;
} else {
putniKartica.classList.add('hidden');
}
}
// Prebacivanje u loaded stanje -> Tailwind CSS automatski pali radni prostor
sekcija.setAttribute('data-state', 'loaded');
} catch (err) {
console.error("Krah servisnog terminala:", err);
sekcija.setAttribute('data-state', 'error');
}
}
// --- AKCIJA A: ZATVARANJE PUTNOG NALOGA (PATCH na osnovnu stazu) ---
document.getElementById('btn-zatvori-pn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget as HTMLButtonElement;
const kmInput = document.getElementById('zavrsna-km') as HTMLInputElement;
const km = kmInput?.value;
if (!km || parseInt(km, 10) <= 0) {
window.showToast?.("Unesite ispravnu završnu kilometražu vozila.", "error");
return;
}
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-2"></i> Knjiženje kilometara...';
const url = `${API_BASE}${API_BASE.endsWith('/') ? '' : '/'}operativa/putni-nalozi/${aktivniNalog.putni_nalog}/`;
try {
const res = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
zavrsna_km: parseInt(km, 10),
status: 'završen'
})
});
if (res.ok) {
window.showToast?.("Putni nalog uspješno zaključen i arhiviran.", "success");
document.getElementById('putni-nalog-kartica')?.classList.add('opacity-40');
btn.classList.add('hidden');
kmInput.disabled = true;
} else {
window.showToast?.("Greška pri zatvaranju putnog lista.", "error");
}
} catch {
window.showToast?.("Mrežna greška u komunikaciji s DRF-om.", "error");
} {
btn.disabled = false;
btn.innerHTML = originalText;
}
});
// --- AKCIJA B: ZATVARANJE RADNOG NALOGA + PROMJENA STATUSI STROJA ---
document.getElementById('btn-zatvori-rn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget as HTMLButtonElement;
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-2"></i> Zatvaranje naloga i ažuriranje flote...';
// 1. Koristimo postojeću metodu patchNalog za prebacivanje radnog naloga u 'zavrsen'
const resNalog = await patchNalog(aktivniNalog.id, { status: 'zavrsen' });
if (resNalog) {
// 2. REST LANČANA REAKCIJA: Šaljemo PATCH izravno na stazu stroja i prebacujemo ga u 'aktivno'
if (aktivniNalog.stroj) {
const urlStroj = `${API_BASE}${API_BASE.endsWith('/') ? '' : '/'}fleet/strojevi/${aktivniNalog.stroj}/`;
await fetch(urlStroj, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({ status: 'aktivno' }) // Stroj je popravljen i spreman za pogon
});
}
window.showToast?.("Radni nalog dovršen. Stroj je uspješno pušten u rad!", "success");
// Re-render cijelog terminala (prebaciće u prazno stanje jer više nema naloga 'u_radu')
setTimeout(() => inicijalizirajTerminalServisera(), 1200);
} else {
window.showToast?.("Sinkronizacija s DRF poslužiteljem nije uspjela.", "error");
btn.disabled = false;
btn.innerHTML = originalText;
}
});
// Re-inicijalizacija i stabilnost kroz View Transitions SPA navigaciju
inicijalizirajTerminalServisera();
document.addEventListener('astro:after-swap', inicijalizirajTerminalServisera);
</script>

View File

@@ -54,7 +54,7 @@
"icon": "fa-address-book",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Pregled",
"welcomeHeaderTextH1dodatno": "klijenata",
"welcomeHeaderTextH1dodatno": " klijenata",
"welcomeHeaderPodnaslov": "Upravljanje bazom korisnika i partnera",
"welcomeHeaderPovratniURL": "/"
},
@@ -64,7 +64,7 @@
"icon": "fa-calendar-days",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Kalendar",
"welcomeHeaderTextH1dodatno": "događaja",
"welcomeHeaderTextH1dodatno": " događaja",
"welcomeHeaderPodnaslov": "Pregled operacija u stvarnom vremenu",
"welcomeHeaderPovratniURL": "/"
},
@@ -87,6 +87,16 @@
"welcomeHeaderTextH1dodatno": " Radni nalog",
"welcomeHeaderPodnaslov": "Otvaranje novog servisnog ili radnog naloga u sustavu",
"welcomeHeaderPovratniURL": "/operativa/radni-nalozi"
},
{
"name": "Evidencija Servisera",
"url": "/operativa/serviseri",
"icon": "fa-calendar-days",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Evidencija",
"welcomeHeaderTextH1dodatno": " servisera",
"welcomeHeaderPodnaslov": "Središnji pregled i administracija terenskih servisnih tehničara",
"welcomeHeaderPovratniURL": "/"
}
],
"api_endpoints": {

View File

@@ -4,27 +4,38 @@
const RAW_BASE = import.meta.env.PUBLIC_API_URL;
const API_BASE = RAW_BASE.endsWith('/') ? RAW_BASE : `${RAW_BASE}/`;
/**
* POMOĆNA FUNKCIJA: Dohvaća token i postavlja Headere
* POMOĆNA FUNKCIJA: Dohvaća token i postavlja Headere.
* Podržava i klijentski localStorage i serverski (SSR) proslijeđeni token.
* @param {Object|FormData} bodyData - Podaci koji se šalju u body-ju zahtjeva
* @param {string|null} ssrToken - Izborni token proslijeđen sa serverske strane (Astro SSR)
*/
function getAuthHeaders(bodyData = {}) {
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
function getAuthHeaders(bodyData = {}, ssrToken = null) {
const headers = {};
// 🚀 PAMETAN DOHVAT TOKENA: Ako je proslijeđen ssrToken (server), koristi njega.
// Ako nije, a nalazimo se u pregledniku (klijent), povuci ga iz localStorage.
let token = ssrToken;
if (!token && typeof window !== 'undefined') {
token = localStorage.getItem('access_token');
}
if (token) {
headers['Authorization'] = `Bearer ${token}`; // Usklađeno sa Simple JWT standardom
}
// Provjera je li bodyData FormData (za slike/naloge)
// Provjera je li bodyData FormData (za slike/naloge s terena)
const isFormData = bodyData instanceof FormData;
// Ako NIJE FormData, šaljemo JSON
// Ako NIJE FormData, šaljemo standardni JSON content type
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
/**
* Centralizirana obrada odgovora s Toast podrškom
*/
@@ -69,9 +80,13 @@ async function handleResponse(res) {
// --- RUTE ---
export const routes = {
// Autentifikacija (Maknute početne kose crte!)
// Autentifikacija
login: () => 'token/',
trenutniKorisnik: () => 'users/me/',
// Korisnici i operativni tehničari
serviseriSvi: () => 'users/',
serviserTerminal: (id) => `users/${id}/terminal/`,
// Radni nalozi
radniNalozi: (params = {}) => {
@@ -109,22 +124,72 @@ export const routes = {
// --- API METODE ---
/**
* Dohvat profila trenutno prijavljenog korisnika
* Dohvat profila korisnika iz opće liste /api/users/
* @param {string|null} ssrToken - Opcionalni token sa servera (Astro.cookies ili slično)
*/
export async function fetchCurrentUser() {
export async function fetchCurrentUser(ssrToken = null) {
if (typeof window === 'undefined' && !ssrToken) return null;
try {
const url = `${API_BASE}${routes.trenutniKorisnik()}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
headers: getAuthHeaders({}, ssrToken)
});
return await handleResponse(res);
const user = await handleResponse(res);
if (user && user.detail) return null;
return user;
} catch (e) {
console.error("fetchCurrentUser Failure:", e);
return null;
}
}
/**
* 🚀 NOVO (DRY): Dohvaća sve korisnike i vraća isključivo filtrirane servisere s terena
*/
export async function fetchServiseri() {
try {
const url = `${API_BASE}${routes.serviseriSvi()}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
const korisnici = await handleResponse(res);
if (!korisnici) return [];
const lista = Array.isArray(korisnici) ? korisnici : (korisnici.results || []);
// Filtriranje na temelju polja 'uloga' iz tvog CustomUser modela
return lista.filter(u => String(u.uloga).toUpperCase().trim() === 'SERVISER');
} catch (e) {
console.error("fetchServiseri Failure:", e);
return [];
}
}
/**
* 🚀 NOVO (DRY): Dohvaća ugniježđene terminal podatke za specifičnog servisera (Nalozi, Putni, Vozila)
* @param {number|string} id - ID servisera čiji se terminal učitava
*/
export async function fetchServiserTerminalData(id) {
try {
const url = `${API_BASE}${routes.serviserTerminal(id)}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
return await handleResponse(res);
} catch (e) {
console.error(`fetchServiserTerminalData za ID ${id} neuspješan:`, e);
return null;
}
}
/**
* Dohvat i filtriranje radnih naloga (za tablice i liste u operativi)
*/

View File

@@ -8,19 +8,14 @@ import StatsGrid from "../components/StatsGrid.astro";
import Kalendar from "../components/Kalendar/Kalendar.astro";
// API i Centralizirani Utils iz api.js
import { fetchDashboardData, fetchCurrentUser } from "../lib/api";
import { fetchDashboardData } from "../lib/api";
// 1. Dohvat podataka (paralelno radi brzine i performansi u homelabu)
const [data, user] = await Promise.all([
fetchDashboardData(),
fetchCurrentUser()
]);
// 1. Dohvat podataka s backenda (Samo bazični dashboard podaci koji se mogu poslužiti asinkrono)
const data = await fetchDashboardData();
const nalozi = data?.nalozi || [];
const imeKorisnika = user?.first_name || "Kolega";
// 2. POPRAVLJENO: Kalkulacija statistike tolerantna na velika/mala slova (.toLowerCase())
// Osiguravamo da se podaci iz baze (PLANIRANO, U_RADU) savršeno upare sa StatsGrid filterima
// 2. Kalkulacija statistike tolerantna na velika/mala slova (.toLowerCase())
const naloziURadu = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
const planiraniNalozi = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
@@ -33,7 +28,7 @@ const zavrseniNalozi = nalozi.filter(n => {
<Layout title="Dashboard | ServisLog">
<div class="w-full space-y-5">
<WelcomeHeader ime={imeKorisnika} />
<WelcomeHeader naslov="Kontrolna ploča" />
<div class="grid grid-cols-3 gap-2 sm:gap-6 px-2">
<StatsGrid
@@ -84,10 +79,19 @@ const zavrseniNalozi = nalozi.filter(n => {
<script>
import { initFilters } from "../scripts/filters.js";
import { provjeriPristupStranici } from "../utils/auth.js";
// Inicijalizacija filtera za StatsGrid klikove na klijentu
initFilters();
// 🚀 POKREĆEMO SIGURNOSNI KLIJENTSKI GUARD:
// On odmah na klijentu provjerava is_serviser. Ako je ulogiran običan serviser,
// prisilno ga i trenutno preusmjerava na njegov dinamički terminal /operativa/serviseri/[id]
async function inicijalizirajStranicu() {
await provjeriPristupStranici();
initFilters();
}
// Re-inicijalizacija kod navigacije (Astro View Transitions)
document.addEventListener('astro:after-swap', initFilters);
// Pokreni odmah pri učitavanju u preglednik
inicijalizirajStranicu();
// Re-inicijalizacija kod klijentske navigacije (Astro View Transitions)
document.addEventListener('astro:after-swap', inicijalizirajStranicu);
</script>

View File

@@ -3,23 +3,31 @@
import Layout from "../layouts/Layout.astro";
import WelcomeHeader from "../components/WelcomeHeader.astro";
import Kalendar from "../components/Kalendar/Kalendar.astro";
// API i Utils
import { fetchCurrentUser } from "../lib/api";
// 1. Dohvaćamo samo korisnika za pozdrav u headeru
const user = await fetchCurrentUser();
const imeKorisnika = user?.first_name || "Kolega";
---
<Layout title="Kalendar | ServisLog">
<div class="w-full space-y-10">
<WelcomeHeader ime={imeKorisnika} />
<WelcomeHeader naslov="Kalendar događaja" />
<div class="grid grid-cols-1 gap-2 sm:gap-6 px-2">
<Kalendar />
</div>
</div>
</Layout>
</Layout>
<script>
import { provjeriPristupStranici } from "../utils/auth.js";
async function inicijalizirajStranicu() {
// 🚀 SIGURNOSNI UKLOP: Guard provjerava sesiju na klijentu i štiti kalendar od neautoriziranih upada
await provjeriPristupStranici();
}
// Pokreni odmah pri učitavanju u preglednik
inicijalizirajStranicu();
// Re-inicijalizacija kod klijentske navigacije kroz ERP (Astro View Transitions)
document.addEventListener('astro:after-swap', inicijalizirajStranicu);
</script>

View File

@@ -0,0 +1,51 @@
---
// src/pages/operativa/serviseri/[id].astro
import Layout from "../../../layouts/Layout.astro";
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
import RadniNalogLista from "../../../components/RadniNalogLista.astro";
import AkcijePanel from "../../../components/AkcijePanel.astro";
// 🚀 Hvatamo ID servisera iz same putanje URL-a
const { id } = Astro.params;
// 🎯 POPRAVAK: Uklonjen serverski fetchCurrentUser i redirect jer se token nalazi na klijentu!
---
<Layout title="Servisni Terminal | Operativa">
<div class="w-full space-y-10">
<WelcomeHeader />
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
<div class="md:col-span-7 space-y-6">
{/* Univerzalna CSR lista koja sama povlači naloge preko api.js ovisno o ulogi */}
<RadniNalogLista
limit={10}
naslov="Dodijeljeni zadaci servisera"
prikaziNaslov={true}
/>
</div>
<div class="md:col-span-3">
<AkcijePanel tip="dashboard" />
</div>
</div>
</div>
</Layout>
<script>
import { initFilters } from "../../../scripts/filters.js";
// 🎯 POPRAVAK: Potpuno izbačen provjeriPristupStranici.
// Ako korisnik nema pravo gledati ovaj profil, Django backend će presresti zahtjev unutar RadniNalogLista!
const pokreniSveKlijentskeSkripte = () => {
initFilters();
};
pokreniSveKlijentskeSkripte();
document.addEventListener('astro:after-swap', pokreniSveKlijentskeSkripte);
</script>

View File

@@ -0,0 +1,136 @@
---
// src/pages/operativa/serviseri/index.astro
import Layout from "../../../layouts/Layout.astro";
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
import RadniNalogLista from "../../../components/RadniNalogLista.astro";
---
<Layout title="Evidencija Servisera | Operativa">
<div class="w-full space-y-10">
<WelcomeHeader />
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
<div id="direktorij-sekcija" class="hidden md:col-span-10 space-y-6">
<div class="bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-[2.5rem] p-6 sm:p-8 shadow-xl shadow-gray-100/40 dark:shadow-none">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 pb-4 border-b border-gray-50 dark:border-gray-800">
<div>
<h2 class="text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight leading-none italic">
Registrirani <span class="text-blue-600">Serviseri</span>
</h2>
<p class="text-xs text-gray-400 mt-2 italic font-medium">Popis tehničara s aktivnim ovlastima za radne naloge</p>
</div>
<div>
<div class="bg-blue-50 dark:bg-blue-950/40 text-blue-600 dark:text-blue-400 border border-blue-100/30 dark:border-blue-900/30 italic font-black uppercase tracking-widest text-[11px] px-4 py-2 rounded-2xl w-max">
Ukupno: <span id="serviseri-counter">0</span>
</div>
</div>
</div>
<div id="serviseri-loading" class="py-12 flex flex-col items-center justify-center gap-3 text-gray-400 dark:text-gray-500 italic text-sm">
<i class="fa-solid fa-circle-notch animate-spin text-blue-600 text-2xl"></i>
Dohvaćanje operativnih podataka s poslužitelja...
</div>
<div id="serviseri-grid" class="hidden grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
</div>
<div id="serviseri-empty" class="hidden py-12 text-center text-gray-400 dark:text-gray-500 italic text-sm">
<i class="fa-solid fa-user-slash text-2xl mb-2 text-gray-300 dark:text-gray-700 block"></i>
Nema registriranih servisera u bazi podataka flote.
</div>
</div>
</div>
<div id="moji-nalozi-sekcija" class="hidden md:col-span-10 space-y-6">
<RadniNalogLista
limit={10}
prikaziNaslov={true}
/>
</div>
</div>
</div>
</Layout>
<script>
import { fetchCurrentUser, fetchServiseri } from "../../../lib/api";
async function ucitajStranicuServisera() {
const direktorijSekcija = document.getElementById('direktorij-sekcija');
const loadingEl = document.getElementById('serviseri-loading');
const gridEl = document.getElementById('serviseri-grid');
const emptyEl = document.getElementById('serviseri-empty');
const counterEl = document.getElementById('serviseri-counter');
const mojiNaloziSekcija = document.getElementById('moji-nalozi-sekcija');
try {
const user = await fetchCurrentUser();
const ulogaGore = user?.uloga ? String(user.uloga).toUpperCase().trim() : '';
// 1. Ako je običan serviser, prikaži sekciju s univerzalnom listom
if (ulogaGore === 'SERVISER') {
if (direktorijSekcija) direktorijSekcija.classList.add('hidden');
if (mojiNaloziSekcija) mojiNaloziSekcija.classList.remove('hidden');
return;
}
// 2. Ako je Admin / Voditelj, prikaži imenik tehničara
if (direktorijSekcija) direktorijSekcija.classList.remove('hidden');
if (mojiNaloziSekcija) mojiNaloziSekcija.classList.add('hidden');
const serviseri = await fetchServiseri();
if (loadingEl) loadingEl.classList.add('hidden');
if (!serviseri || serviseri.length === 0) {
if (emptyEl) emptyEl.classList.remove('hidden');
return;
}
if (counterEl) counterEl.textContent = serviseri.length.toString();
if (gridEl) {
gridEl.innerHTML = serviseri.map((s: any) => {
const inicijali = `${s.first_name?.[0] || ''}${s.last_name?.[0] || ''}`.toUpperCase() || 'ST';
const imePrezime = `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email;
return `
<div class="bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100 dark:border-gray-800/60 rounded-[1.8rem] p-5 flex items-center justify-between gap-4 hover:border-blue-500/30 dark:hover:border-blue-500/20 hover:bg-white dark:hover:bg-gray-800/60 transition-all duration-300 group">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 font-black text-xs tracking-widest rounded-2xl flex items-center justify-center border border-blue-500/10 shadow-sm">
${inicijali}
</div>
<div class="text-left">
<h3 class="text-sm font-black text-gray-900 dark:text-white uppercase tracking-tight leading-none group-hover:text-blue-600 transition-colors italic">
${imePrezime}
</h3>
<p class="text-[10px] font-medium text-gray-400 mt-2 lowercase select-all">${s.email}</p>
</div>
</div>
<a href="/operativa/serviseri/${s.id}" class="w-10 h-10 flex items-center justify-center rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/80 text-gray-400 group-hover:text-white group-hover:bg-blue-600 group-hover:border-blue-500 active:scale-90 shadow-sm transition-all cursor-pointer">
<i class="fa-solid fa-chevron-right text-xs group-hover:translate-x-0.5 transition-transform"></i>
</a>
</div>
`;
}).join('');
gridEl.classList.remove('hidden');
gridEl.classList.add('grid');
}
} catch (err) {
console.error("Greška pri učitavanju stranice:", err);
if (loadingEl) loadingEl.classList.add('hidden');
// 🚀 TOAST OKIDAMO SAMO OVDJE NA GLAVNOJ STRANICI ZA IMENIK
// @ts-ignore
window.showToast?.("Sinkronizacija imenika flote neuspješna.", "error");
}
}
ucitajStranicuServisera();
document.addEventListener('astro:after-swap', ucitajStranicuServisera);
</script>

View File

@@ -0,0 +1,61 @@
// src/utils/auth.js
import { fetchCurrentUser } from "../lib/api";
/**
* Pomoćna funkcija za dohvat sigurne rute (Single Source of Truth)
*/
export function dohvatiRutuPoUlozi(uloga, userId = '') {
const ulogaGore = uloga ? String(uloga).toUpperCase().trim() : '';
if (ulogaGore === 'SERVISER') {
return userId ? `/operativa/serviseri/${userId}` : '/operativa/serviseri/';
}
return '/';
}
/**
* Jednostavan klijentski guard koji samo provjerava valjanost sesije i ovlasti
* @param {string[]} dozvoljeneUloge - Niz uloga koje smiju vidjeti stranicu
*/
export async function provjeriPristupStranici(dozvoljeneUloge = []) {
if (typeof window === 'undefined') return;
const token = localStorage.getItem('access_token');
// 1. Ako uopće nema tokena, vrati ga na prijavu
if (!token) {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return;
}
try {
// 2. Dohvaćamo profil trenutnog korisnika s backenda
const user = await fetchCurrentUser();
// 3. Ako je token nevažeći/istekao, počisti local pohranu i izbaci ga van
if (!user || !user.uloga) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
return;
}
const korisnikUlogaGore = String(user.uloga).toUpperCase().trim();
// 4. Provjera eksplicitnih ovlasti (Samo ako ih je stranica zatražila)
if (dozvoljeneUloge.length > 0) {
const dozvoljeneUlogeGore = dozvoljeneUloge.map(u => String(u).toUpperCase().trim());
// Ako korisnik nema ulogu koja je navedena u dozvoljenima za tu stranicu
if (!dozvoljeneUlogeGore.includes(korisnikUlogaGore)) {
// Saznaj kamo pripada s obzirom na ulogu i preusmjeri ga
window.location.href = dohvatiRutuPoUlozi(korisnikUlogaGore, user.id);
return;
}
}
} catch (err) {
console.error("Kritičan krah u provjeri pristupa:", err);
}
}

View File

@@ -0,0 +1,400 @@
# Upit
```md
Obrazloži kao da radiš prezentaciju kako je implementirano rješenje preko localStorage
Obrazloži kako se mijenja stanje u UI iz loading u loaded
Obrazloži što je potrebno na django rest frameworku da ovo funkcionira
```
# Odgovor (Gemini)
Evo izravne inženjerske prezentacije i analize arhitekture sustava, fokusirane **isključivo** na priložene datoteke (`api.js`, `index.astro` i `RadniNalogLista.astro`), s jasnim obrazloženjem svih traženih mehanizama i DRF preduvjeta.
---
## 1. Implementacija autentifikacije i autorizacije preko `localStorage`-a
U priloženom kodu, upravljanje sesijom i dokazivanje identiteta prema Django backendu u potpunosti se oslanja na klijentski `localStorage`.
```
[Klijent: login()] ───> POST /token/ ───> [Django Simple JWT]
(access & refresh) <───┘
localStorage.setItem('access_token', data.access)
[Klijent: getAuthHeaders()] ──┴──> Headers: { Authorization: Bearer <token> }
```
### Detaljna mehanika unutar priloženih datoteka:
*
**Pohrana pri prijavi:** Unutar `api.js`, funkcija `login(email, password)` šalje POST zahtjev na endpoint definiran u `routes.login()`. Kada Django odobri pristup, asinkroni kôd presreće odgovor i upisuje tokene u memoriju preglednika:
```javascript
localStorage.setItem('access_token', data.access);
localStorage.setItem('refresh_token', data.refresh);
```
*
**Presretanje i slaganje zaglavlja (Interceptors):** Funkcija `getAuthHeaders(bodyData)` služi kao centralni generator sigurnosnih metapodataka. Ona prvo provjerava nalazi li se kôd u kontekstu preglednika kako bi sigurno pristupila `localStorage`-u bez rušenja Node.js okruženja:
```javascript
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
```
Ako token postoji, on se ubacuje u standardni format Simple JWT-a: `headers['Authorization'] = 'Bearer ' + token`.
*
**Automatsko čišćenje:** Funkcija `logout()` u `api.js` rješava suprotan proces briše ključeve `access_token` i `refresh_token` iz `localStorage`-a te preusmjerava korisnika na stranicu za prijavu.
---
## 2. Tranzicija stanja u UI-ju iz *Loading* u *Loaded*
Budući da su priložene datoteke `index.astro` i `RadniNalogLista.astro` u ovom trenutku konfigurirane kao **serverske komponente (SSR)** , tranzicija stanja iz *Loading* u *Loaded* odvija se na razini samog poslužitelja (Node.js/Docker) prije nego što HTML uopće stigne do preglednika.
### Korak po korak: Kako se mijenja stanje unutar priloženog koda
1. **Asinkroni paralelni dohvat (Podaci na čekanju / "Loading"):**
Na samom vrhu `index.astro`, unutar frontmattera (`---`), pokreće se paralelni dohvat podataka s baze pomoću `Promise.all`. U tom milisekundnom prozoru, dok Node.js čeka odgovor s Django API-ja, aplikacija je u "Loading" stanju na razini poslužitelja:
```javascript
const [data, user] = await Promise.all([ fetchDashboardData(), fetchCurrentUser() ]);
```
2. **Obrada i punjenje strukture (Data Hydration / "Loaded"):**
Čim se `Promise.all` razriješi, stanje prelazi u "Loaded" na serveru. Podaci se destrukturiraju unutar `index.astro` i `RadniNalogLista.astro`. Istog trenutka, asinkrone varijable popunjavaju statička mjesta u kodu:
*
`user?.first_name` definira pozdrav u `WelcomeHeader` komponenti.
*
`nalozi.filter(...)` proračunava točne brojke za `StatsGrid` kartice (Planirano, U radu, Gotovo).
3. **Isporuka čistog HTML-a klijentu:**
Komponenta `RadniNalogLista.astro` prima podatke iz `fetchDashboardData()`. Ako nalozi postoje, `.map()` funkcija generira njezinu strukturu i iscrtava gotove `GenericKarticaItem` komponente s točnim brojevima i stanjima. Korisnik na ekranu ne vidi nikakvo učitavanje niti kosture, već mu se stranica otvara trenutno napunjena podacima.
---
## 3. Što je potrebno na Django REST Frameworku da ovo funkcionira
Pregledom poslanih klijentskih metoda unutar `api.js`, tvoj Django backend mora striktno podržavati sljedeće DRF značajke:
### A. Konfiguracija Simple JWT Autentifikacije
Unutar `api.js`, funkcija `getAuthHeaders()` injektira `Authorization: Bearer <token>`. Da bi DRF prepoznao ovaj potpis, u tvojem Django `settings.py` mora biti uključen Simple JWT:
```python
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
}
```
Također, endpoint za prijavu unutar Django `urls.py` mora odgovarati stazi `token/` koju poziva `routes.login()`.
### B. Podrška za pametno prepoznavanje Content-Type-a (JSON vs Multipart)
Funkcija `getAuthHeaders()` dinamički prilagođava zaglavlja ovisno o tome šalju li se sirovi tekstualni podaci ili binarne datoteke s terena:
```javascript
const isFormData = bodyData instanceof FormData;
if (!isFormData) { headers['Content-Type'] = 'application/json'; }
```
*
**Za `createVozilo` i `patchNalog`:** Šalje se čisti `application/json`. Django mora imati uključen `JSONParser` (što je DRF default).
*
**Za `createNalog`:** Šalje se `FormData` objekt jer učitavaš slike s terena (`RadniNalogSlika`). Tvoj Django `RadniNalogViewSet` (ili pripadajuća funkcija) mora eksplicitno imati definiran `parser_classes = [MultiPartParser, FormParser]` kako bi ispravno razdvojio tekstualna polja od slikovnih datoteka unutar `request.data`.
### C. Dinamičko filtriranje i Query Parameters na ViewSetovima
Unutar `api.js`, objekt `routes` dinamički gradi query stringove za filtriranje:
*
`radniNalozi(params)` gradi string poput `operativa/radni-nalozi/?status=u_radu` ili filtrira prema vozilu.
*
`strojevi(vlasnikId)` gradi stazu `fleet/strojevi/?vlasnik=id`.
Da bi ovo radilo, tvoji DRF ViewSetovi na backendu moraju implementirati `django-filter` backend ili ručno prepisati `get_queryset()` metodu:
```python
# Primjer na backendu za fleet/strojevi/
def get_queryset(self):
queryset = Stroj.objects.all()
vlasnik_id = self.request.query_params.get('vlasnik')
if vlasnik_id:
queryset = queryset.filter(vlasnik_id=vlasnik_id)
return queryset
```
### D. CORS (Cross-Origin Resource Sharing) Propusnost
Budući da Astro aplikacija šalje HTTP zahtjeve s jedne adrese/porta na `PUBLIC_API_URL` (koji je definiran u `API_BASE`) , tvoj Django mora imati instaliran `django-cors-headers` paket te dopuštenu domenu frontenda kroz `CORS_ALLOWED_ORIGINS` konfiguraciju kako preglednik ne bi blokirao asinkrone `fetch` pozive.
# Client-Side Rendering - CSR
Evo kompletnog, tehnički strukturiranog i rafiniranog nacrta prezentacije za tvoj *ServisLog Terminal*. Odgovor je fokusiran **isključivo na priložene datoteke (`api.js`, `index.astro` i tvoj zadnji klijentski `RadniNalogLista.astro`)** te detaljno obuhvaća novu logiku sinkronizacije stanja između učitavanja podataka i upravljanja vizualnim elementima u `NaslovList.astro`.
---
# PREZENTACIJA: Migracija na CSR i Sinkronizacija Stanja Sučelja
## SLIJD 1: Arhitektura Autentifikacije i Tok Tokena (localStorage)
Uklanjanjem ovisnosti o kolačićima na poslužitelju, aplikacija je prebačena na stopostotni klijentski model autorizacije. To eliminira serverske krahove (`Given token not valid...`) jer se provjera identiteta odvija unutar izoliranog okruženja preglednika.
```
[Klijent: login()] ───> POST /api/token/ ───> [Django REST Framework]
(access & refresh) <───┘
localStorage.setItem('access_token', data.access)
[Klijent: getAuthHeaders()] ──┴──> Headers: { Authorization: Bearer <token> }
```
### Tehnička mehanika unutar priloženih datoteka:
* **Pohrana pri prijavi:** Asinkrona funkcija `login(email, password)` u `api.js` šalje korisničke podatke na backend. Nakon validacije, klijentski kôd presreće odgovor i trajno upisuje tokene u memoriju:
```javascript
localStorage.setItem('access_token', data.access);
localStorage.setItem('refresh_token', data.refresh);
```
* **Centralizirani presretač (getAuthHeaders):** Prije slanja bilo kojeg operativnog zahtjeva (poput `fetchDashboardData()`), funkcija `getAuthHeaders(bodyData)` provjerava postojanje `window` objekta kako bi sigurno pročitala memoriju klijenta:
```javascript
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
```
Ako token postoji, on se injektira u standardno HTTP zaglavlje: `headers['Authorization'] = 'Bearer ' + token`.
---
## SLIJD 2: Životni vijek Tranzicije Sučelja (Loading -> Loaded)
Klijentsko renderiranje donosi asinkroni životni vijek u kojem se elementi sučelja ne prikazuju odjednom, već se postupno aktiviraju (hidriraju) onog trenutka kada podaci stignu s mreže.
### Tri faze tranzicije u sučelju:
1. **Faza Učitavanja (Loading):** Poslužitelj isporučuje kostur stranice. Korisnik odmah vidi animirani krug (`#nalozi-loader`) s pulsirajućim tekstom *"Sinkronizacija radnih naloga..."*. Istovremeno, gornje kartice filtera s brojačima su **potpuno sakrivene** kako korisnik ne bi vidio nule i kako ne bi mogao okinuti preuranjeni klik.
2. **Faza Hidracije (Data Processing):** Klijentski JavaScript u pozadini izvršava `fetchDashboardData()`, prima sirovi niz naloga, preračunava statistiku (`planiranoCount`, `uRaduCount`), filtrira elemente prema stanjima iz URL-a i gradi HTML stabla.
3. **Faza Prikaza (Loaded):** Izvršava se atomska zamjena CSS klasa u DOM-u. Loader se skriva, generirane kartice radnih naloga se ubacuju u kontejner, a kartice s brojačima u zaglavlju glatko postaju vidljive s točnim, svježim vrijednostima.
---
## SLIJD 3: Sinkronizacija Stanja preko Klase `#nalozi-loader` i Varijabli Sučelja
Ovaj mehanizam rješava kritičan problem sinkronizacije: kako spriječiti prikaz praznih stat-kartica u `NaslovList.astro` dok `RadniNalogLista.astro` još uvijek čeka podatke s API-ja.
### Implementacija u `NaslovList.astro`:
Desni kontejner koji drži stat-kartice (`#stat-cards-container`) inicijalno se isporučuje s nultom vidljivošću i blokiranim interakcijama pomoću Tailwind pomoćnih klasa:
```html
<div id="stat-cards-container" class="opacity-0 pointer-events-none transition-opacity duration-300">
</div>
```
### Upravljanje stanjem unutar `RadniNalogLista.astro`:
Unutar asinkrone funkcije `renderirajRadneNalogeKlijentski()`, stanje se mijenja izravnom manipulacijom DOM elemenata tek **nakon uspješnog `try` bloka**:
```javascript
// 1. Upisivanje svježe proračunatih vrijednosti u DOM podkomponente
const planiranoValue = sekcija.querySelector('[data-filter="planirano"] .count-value');
if (planiranoValue) planiranoValue.textContent = planiranoCount.toString();
const uRaduValue = sekcija.querySelector('[data-filter="u_radu"] .count-value');
if (uRaduValue) uRaduValue.textContent = uRaduCount.toString();
// 2. TRANZICIJA STANJA: Uklanjanje loadera i aktivacija kartica
if (loader) {
loader.classList.add('hidden'); // Sakrivamo pulsirajući loader operacije
}
if (statCardsContainer) {
// Gasimo nevidljivost i ponovno dopuštamo klikove na klijentske filtre
statCardsContainer.classList.remove('opacity-0', 'pointer-events-none');
statCardsContainer.classList.add('opacity-100');
}
```
---
## SLIJD 4: Preduvjeti na Django REST Frameworku (Backend)
Da bi klijentski kod iz `api.js` i `RadniNalogLista.astro` radio bez pogrešaka, DRF mora striktno podržavati četiri arhitektonska standarda:
* **CORS (Cross-Origin Resource Sharing) Propusnost:** Budući da Astro šalje asinkrone fetch zahtjeve s klijenta (`localhost:4321`) na domenu backenda, u Django `settings.py` mora biti uključen `django-cors-headers` middleware, a adresa frontenda mora biti upisana u `CORS_ALLOWED_ORIGINS` listu.
* **Simple JWT Validacija:** Backend mora prepoznati i dekodirati `Authorization: Bearer <token>` zaglavlje koje generira funkcija `getAuthHeaders()`. Polje odgovora na `/api/token/` endpointu mora vraćati objekt s ključem `access`.
* **Multi-Parser Podrška (JSON vs Multipart):** Funkcija `getAuthHeaders()` u `api.js` provjerava tip podataka prije slanja:
```javascript
const isFormData = bodyData instanceof FormData;
if (!isFormData) { headers['Content-Type'] = 'application/json'; }
```
To znači da Django ViewSetovi moraju imati omogućene odgovarajuće parsere. Za bazične preglede (`fetchDashboardData`) koristi se `JSONParser`, dok za funkciju kreiranja naloga sa slikama s terena (`createNalog`), Django klasa mora imati `parser_classes = [MultiPartParser, FormParser]`.
* **Query Parametri za Filtriranje:** Kako bi klijentski URL parametri poput `?status=u_radu` vratili ispravne podatke, Django ViewSet mora presretati zahtjeve i filtrirati SQL upite na razini baze kroz `get_queryset()` metodu ili preko `DjangoFilterBackend` paketa prije slanja JSON-a natrag u Astro.
# Server-Side Rendering - SSR
U priloženom kodu datoteke `RadniNalogLista.astro` koji je postavljen kao **SSR (Server-Side Rendering)** komponenta, klasa **`nalozi-loader` uopće ne postoji niti se koristi**.
Međutim, ako tu komponentu želimo prebaciti na **klijentsko renderiranje (Client-side rendering)** kako bi vukla podatke izravno iz `localStorage`-a u pregledniku, uvođenje klase/ID-ja `nalozi-loader` postaje ključni mehanizam za upravljanje stanjem sučelja.
Evo detaljnog obrazloženja kako taj mehanizam točno funkcionira kroz životni vijek klijentske komponente, podijeljenog u tri cjeline:
---
## 1. Konceptualni prikaz: Životni vijek tranzicije sučelja
Kada se učitavanje prebaci na klijenta, sučelje prolazi kroz asinkroni proces zamjene elemenata u DOM-u:
```
[Klijent otvara stranicu]
├──> Renderira se samo statični HTML skeleton s loaderom
│ (Vidljiv element: #nalozi-loader, Sakriven element: #nalozi-list)
├──> Okida se asinkroni JavaScript: fetchDashboardData()
│ (Preglednik čita JWT token iz localStorage-a i šalje zahtjev)
[Podaci stigli s Django API-ja]
├──> JS generira HTML kartice unutar #nalozi-list
└──> ATOMSKA ZAMJENA STANJA (Pomoću CSS klasa):
#nalozi-loader ──> .classList.add('hidden')
#nalozi-list ──> .classList.remove('hidden')
```
---
## 2. Implementacija unutar strukture `RadniNalogLista.astro`
Unutar samog Astro HTML koda (ispod frontmattera), struktura se postavlja tako da loader zauzima cijeli prostor predviđen za listu, sprječavajući "skakanje" sučelja (*layout shift*) dok se podaci čekaju:
```html
<div class="space-y-6" id="radni-nalozi-sekcija">
<NaslovList ... />
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl overflow-hidden">
<div id="nalozi-loader" class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse">
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i>
Sinkronizacija radnih naloga...
</div>
<div id="nalozi-list" class="space-y-0"></div>
</div>
</div>
```
---
## 3. Kako JavaScript upravlja loaderom i stanjem (Loaded)
Unutar `<script>` bloka u komponenti, logika pronalazi element `nalozi-loader` preko njegovog ID-ja ili klase i manipulira njegovom vidljivošću pomoću Tailwindovih pomoćnih klasa:
```javascript
<script>
import { fetchDashboardData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
async function renderirajRadneNaloge() {
const container = document.getElementById('nalozi-list');
const loader = document.getElementById('nalozi-loader'); // <--- Hvatanje loader elementa
if (!container) return;
try {
// 1. Pokretanje mrežnog zahtjeva (Korisnik u ovom trenu i dalje vidi vrteći loader)
const data = await fetchDashboardData();
const nalozi = data?.nalozi || [];
// 2. Generiranje HTML strukture za kartice (Data Hydration)
if (nalozi.length === 0) {
container.innerHTML = `<div class="p-20 text-center uppercase">Nema zapisa</div>`;
} else {
container.innerHTML = nalozi.map(n => {
return ``;
}).join('');
}
// 3. 🛠️ ATOMSKA ZAMJENA STANJA (Iz Loading u Loaded)
// Kada je HTML uspješno generiran i umetnut u DOM, skrivamo loader dodavanjem klase 'hidden'
if (loader) {
loader.classList.add('hidden');
}
} catch (err) {
console.error("Greška pri renderu:", err);
if (loader) {
// U slučaju greške, loader gasi animaciju i prikazuje poruku o grešci
loader.classList.remove('animate-pulse');
loader.innerHTML = '<span class="text-red-500">Greška pri sinkronizaciji podataka.</span>';
}
}
}
// Pokretanje funkcije
renderirajRadneNaloge();
</script>
```
### Zašto je ovaj pristup s `nalozi-loader` tehnički superiorniji za klijentski render?
1. **Nema praznog prostora:** Korisnik odmah dobiva povratnu informaciju (animirani krug i pulsirajući tekst) da aplikacija komunicira s Proxmox/Django backendom.
2. **JWT Sigurnost:** Budući da se `fetchDashboardData()` izvršava tek unutar ovog klijentskog skripta, funkcija uspješno čita token iz `localStorage.getItem('access_token')`, što u potpunosti rješava grešku `Given token not valid for any token type` s kojom si se susretao na serverskoj strani aplikacije.

View File

@@ -0,0 +1,160 @@
## Layout.astro
```astro
---
// src/layouts/Layout.astro
import Nav from "../components/Navbar.astro";
import Toast from '../components/Toast.astro';
import site from "../data/site.json";
import "../styles/global.css"; // LOKALNI RESURSI
interface Props {
title?: string;
description?: string;
}
const {
title = site.title,
description = site.description
} = Astro.props;
---
<!doctype html>
<html lang="hr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
<title>{title} | {site.title}</title>
<!-- LOKALNI RESURSI -->
<link rel="stylesheet" href="/css/all.min.css" />
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white antialiased min-h-screen flex flex-col">
<Nav />
<main class="flex-grow w-full max-w-7xl mx-auto pt-24 pb-12 px-4 sm:px-6 lg:px-8">
<slot />
</main>
<footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-6 text-center text-sm text-gray-500">
&copy; {new Date().getFullYear()} {site.title}. Sva prava pridržana.
</footer>
<Toast />
<!-- LOKALNI RESURSI -->
<script is:inline src="/js/flowbite.min.js"></script>
</body>
</html>
<style is:global>
/* Integracija Tailwinda unutar Layouta više ne sadrži @tailwind direktive
jer su one sada pravilno konfigurirane unutar src/styles/global.css rute */
</style>
```
## Staviti u /public resurse koji se ne mijenjaji i koje preglednik mora dohvatiti točno onakve kakve jesu
- flowbite.min.js
- već gotovi kompajlirani CSS
- roboti
- manifesti
- ikone
### Korak 1: Dohvati resurse
```bash
# 1. Kreiranje strukture mapa unutar public direktorija
mkdir -p public/js public/css
# 2. Preuzimanje Flowbite JS-a u public/js mapu
wget -O public/js/flowbite.min.js https://cdn.jsdelivr.net/npm/flowbite@2.5.2/dist/flowbite.min.js
# 3. Preuzimanje FontAwesome CSS-a u public/css mapu
wget -O public/css/all.min.css https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css
```
Font Awesome ikone je potrebno staviti u folder webfonts (public/webfonts)
```bash
# Kreiranje mape za fontove ikona
mkdir -p public/webfonts
# Preuzimanje ključnih fontova koje FontAwesome koristi za renderiranje ikona
wget -P public/webfonts/ https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/webfonts/fa-solid-900.woff2
wget -P public/webfonts/ https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/webfonts/fa-regular-400.woff2
wget -P public/webfonts/ https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/webfonts/fa-brands-400.woff2
```
```bash
# 1. Kreiranje mape za Inter font unutar javnih resursa
mkdir -p public/webfonts/inter
# 2. Preuzimanje .woff2 datoteka izravno s provjerenog upstream repozitorija
wget -O public/webfonts/inter/inter-400.woff2 https://cdn.jsdelivr.net/fontsource/fonts/inter@5.0.7/latin-400-normal.woff2
wget -O public/webfonts/inter/inter-500.woff2 https://cdn.jsdelivr.net/fontsource/fonts/inter@5.0.7/latin-500-normal.woff2
wget -O public/webfonts/inter/inter-600.woff2 https://cdn.jsdelivr.net/fontsource/fonts/inter@5.0.7/latin-600-normal.woff2
wget -O public/webfonts/inter/inter-700.woff2 https://cdn.jsdelivr.net/fontsource/fonts/inter@5.0.7/latin-700-normal.woff2
wget -O public/webfonts/inter/inter-800.woff2 https://cdn.jsdelivr.net/fontsource/fonts/inter@5.0.7/latin-800-normal.woff2
```
### Korak 2: Poveži resurse u src/styles/global.css
```css
/* src/styles/global.css */
/* --- LOKALNE DEFINICIJE INTER FONTA --- */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/webfonts/inter/inter-400.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/webfonts/inter/inter-500.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/webfonts/inter/inter-600.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/webfonts/inter/inter-700.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('/webfonts/inter/inter-800.woff2') format('woff2');
}
/* --- TAILWIND V4 KONFIGURACIJA TEME --- */
@theme {
/* Postavljamo naš lokalni Inter kao primarni sans-serif font aplikacije */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
}
```