Compare commits
2 Commits
e308cd8d13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 668195e642 | |||
| 80f9ba3879 |
@@ -125,6 +125,15 @@ SIMPLE_JWT = {
|
|||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, '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
|
# CORS POSTAVKE - POPRAVLJENI ZAREZI I FORMALNI ORIGINI
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
CORS_ALLOW_ALL_ORIGINS = True
|
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 |
@@ -17,6 +17,21 @@ class PutniNalogMinimalSerializer(serializers.ModelSerializer):
|
|||||||
model = PutniNalog
|
model = PutniNalog
|
||||||
fields = ['id', 'broj_naloga', 'status', 'pocetna_km']
|
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 ---
|
# --- 1. LISTA SERIALIZER ---
|
||||||
class RadniNalogListaSerializer(serializers.ModelSerializer):
|
class RadniNalogListaSerializer(serializers.ModelSerializer):
|
||||||
klijent_naziv = serializers.CharField(source='klijent.naziv', read_only=True)
|
klijent_naziv = serializers.CharField(source='klijent.naziv', read_only=True)
|
||||||
|
|||||||
@@ -3,11 +3,21 @@ from django.contrib.auth.admin import UserAdmin
|
|||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
model = CustomUser
|
# 🚀 POPRAVLJENO: Zamijenjen 'is_serviser' s 'uloga' u stupcima tablice
|
||||||
# Definiramo koja polja se vide u admin listi i formama
|
list_display = ['email', 'first_name', 'last_name', 'telefon', 'oib', 'uloga', 'is_staff']
|
||||||
list_display = ['email', 'username', 'first_name', 'last_name', 'is_staff', 'is_serviser']
|
|
||||||
|
# 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 + (
|
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)
|
admin.site.register(CustomUser, CustomUserAdmin)
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,17 +2,29 @@ from django.contrib.auth.models import AbstractUser
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
class CustomUser(AbstractUser):
|
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 koristimo za login, pa mora biti jedinstven
|
||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
|
|
||||||
# Dodatna polja za tvoju tvrtku
|
# Dodatna polja za tvoju tvrtku
|
||||||
telefon = models.CharField(max_length=20, blank=True)
|
telefon = models.CharField(max_length=20, blank=True)
|
||||||
oib = models.CharField(max_length=11, blank=True, null=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
|
# Govorimo Djangu da koristi email umjesto username-a
|
||||||
USERNAME_FIELD = 'email'
|
USERNAME_FIELD = 'email'
|
||||||
REQUIRED_FIELDS = ['username', 'first_name', 'last_name']
|
REQUIRED_FIELDS = ['username', 'first_name', 'last_name']
|
||||||
|
|
||||||
def __str__(self):
|
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()}"
|
||||||
@@ -1,7 +1,96 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import CustomUser
|
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:
|
class Meta:
|
||||||
model = CustomUser
|
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)
|
||||||
37
001.BACKEND/users/services.py
Normal file
37
001.BACKEND/users/services.py
Normal 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
|
||||||
|
}
|
||||||
@@ -1,27 +1,97 @@
|
|||||||
|
# users/views.py
|
||||||
from rest_framework import viewsets, permissions, status
|
from rest_framework import viewsets, permissions, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.conf import settings
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
from .models import CustomUser
|
|
||||||
from rest_framework import serializers
|
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):
|
class UserMeSerializer(serializers.ModelSerializer):
|
||||||
|
uloga = serializers.SerializerMethodField()
|
||||||
|
is_serviser = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
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):
|
class UserViewSet(viewsets.ViewSet):
|
||||||
# Akcija 'me' bit će dostupna na /api/users/me/
|
authentication_classes = [JWTAuthentication]
|
||||||
@action(detail=False, methods=['get'])
|
# 🎯 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):
|
def me(self, request):
|
||||||
# 1. Provjera je li korisnik stvarno logiran (ima validan token)
|
# Ova provjera ostaje kao oporavak u slučaju da JWT autentifikacija propusti prazan objekt
|
||||||
if not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return Response(
|
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
|
try:
|
||||||
serializer = UserMeSerializer(request.user)
|
cisti_korisnik = CustomUser.objects.get(id=request.user.id)
|
||||||
return Response(serializer.data)
|
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
|
||||||
|
)
|
||||||
@@ -10,13 +10,19 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: '0.0.0.0',
|
||||||
port: 4321,
|
port: 4321,
|
||||||
},
|
},
|
||||||
|
|
||||||
image: {
|
image: {
|
||||||
// Dodaj domene s kojih Astro smije povlačiti i optimizirati slike
|
remotePatterns: [
|
||||||
domains: ['localhost', '127.0.0.1','v003-backend.captain.mitteworkspace.cloud'],
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'frontend-operativa.local.mitteworkspace.cloud',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
@@ -24,11 +30,22 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
|
build: {
|
||||||
|
// Smanji intenzitet optimizacije tijekom dev-a
|
||||||
|
minify: false,
|
||||||
|
cssMinify: false,
|
||||||
|
},
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
// KLJUČNO ZA TAILWIND v4 + NODE standalone SSR:
|
// KLJUČNO ZA TAILWIND v4 + NODE standalone SSR:
|
||||||
ssr: {
|
ssr: {
|
||||||
// Prisiljava Vite da uključi Tailwind v4 stilove u serverski bundle
|
// Prisiljava Vite da uključi Tailwind v4 stilove u serverski bundle
|
||||||
noExternal: ['tailwindcss', '@tailwindcss/vite']
|
noExternal: ['tailwindcss', '@tailwindcss/vite']
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/node_modules/**', '**/dist/**'],
|
||||||
|
},
|
||||||
|
allowedHosts: ['.mitteworkspace.cloud']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
469
001.FRONTEND/package-lock.json
generated
469
001.FRONTEND/package-lock.json
generated
@@ -10,7 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^10.0.6",
|
"@astrojs/node": "^10.0.6",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"astro": "^6.1.9",
|
"astro": "^6.3.7",
|
||||||
"flowbite": "^4.0.1",
|
"flowbite": "^4.0.1",
|
||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"tailwindcss": "^4.2.4"
|
"tailwindcss": "^4.2.4"
|
||||||
@@ -203,17 +203,6 @@
|
|||||||
"node": ">= 20.12.0"
|
"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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
@@ -224,16 +213,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
@@ -1161,40 +1140,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@oslojs/encoding": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@@ -1205,270 +1156,6 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"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": {
|
"node_modules/@rollup/plugin-node-resolve": {
|
||||||
"version": "15.3.1",
|
"version": "15.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
|
"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"
|
"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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
@@ -2414,80 +2091,6 @@
|
|||||||
"sharp": "^0.34.0"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -5304,40 +4907,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.4",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||||
@@ -5643,9 +5212,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.1.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||||
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
|
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -6013,17 +5582,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.14",
|
"version": "7.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"esbuild": "^0.27.0",
|
||||||
"picomatch": "^4.0.4",
|
"fdir": "^6.5.0",
|
||||||
"postcss": "^8.5.15",
|
"picomatch": "^4.0.3",
|
||||||
"rolldown": "1.0.2",
|
"postcss": "^8.5.6",
|
||||||
"tinyglobby": "^0.2.16"
|
"rollup": "^4.43.0",
|
||||||
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -6039,10 +5608,9 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.1.18",
|
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
"lightningcss": "^1.21.0",
|
||||||
"sass": "^1.70.0",
|
"sass": "^1.70.0",
|
||||||
"sass-embedded": "^1.70.0",
|
"sass-embedded": "^1.70.0",
|
||||||
"stylus": ">=0.54.8",
|
"stylus": ">=0.54.8",
|
||||||
@@ -6055,18 +5623,15 @@
|
|||||||
"@types/node": {
|
"@types/node": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@vitejs/devtools": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"esbuild": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"jiti": {
|
"jiti": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"less": {
|
"less": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"lightningcss": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"sass": {
|
"sass": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"name": "poslovanje",
|
"name": "poslovanje",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"overrides": {
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.12.0"
|
||||||
},
|
},
|
||||||
@@ -10,12 +13,12 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"start": "node ./dist/server/entry.mjs"
|
"start": "NODE_OPTIONS=--max-old-space-size=4096 npm run dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^10.0.6",
|
"@astrojs/node": "^10.0.6",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"astro": "^6.1.9",
|
"astro": "^6.3.7",
|
||||||
"flowbite": "^4.0.1",
|
"flowbite": "^4.0.1",
|
||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"tailwindcss": "^4.2.4"
|
"tailwindcss": "^4.2.4"
|
||||||
|
|||||||
9
001.FRONTEND/public/css/all.min.css
vendored
Normal file
9
001.FRONTEND/public/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
001.FRONTEND/public/js/flowbite.min.js
vendored
Normal file
2
001.FRONTEND/public/js/flowbite.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
001.FRONTEND/public/webfonts/fa-brands-400.woff2
Normal file
BIN
001.FRONTEND/public/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
001.FRONTEND/public/webfonts/fa-regular-400.woff2
Normal file
BIN
001.FRONTEND/public/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
001.FRONTEND/public/webfonts/fa-solid-900.woff2
Normal file
BIN
001.FRONTEND/public/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
BIN
001.FRONTEND/public/webfonts/inter/inter-400.woff2
Normal file
BIN
001.FRONTEND/public/webfonts/inter/inter-400.woff2
Normal file
Binary file not shown.
BIN
001.FRONTEND/public/webfonts/inter/inter-500.woff2
Normal file
BIN
001.FRONTEND/public/webfonts/inter/inter-500.woff2
Normal file
Binary file not shown.
BIN
001.FRONTEND/public/webfonts/inter/inter-600.woff2
Normal file
BIN
001.FRONTEND/public/webfonts/inter/inter-600.woff2
Normal file
Binary file not shown.
BIN
001.FRONTEND/public/webfonts/inter/inter-700.woff2
Normal file
BIN
001.FRONTEND/public/webfonts/inter/inter-700.woff2
Normal file
Binary file not shown.
BIN
001.FRONTEND/public/webfonts/inter/inter-800.woff2
Normal file
BIN
001.FRONTEND/public/webfonts/inter/inter-800.woff2
Normal file
Binary file not shown.
@@ -54,7 +54,9 @@ import Button from "./Button.astro";
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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 loginForm = document.querySelector('#login-form') as HTMLFormElement;
|
||||||
const btn = document.querySelector('#login-btn') as HTMLButtonElement;
|
const btn = document.querySelector('#login-btn') as HTMLButtonElement;
|
||||||
@@ -64,10 +66,8 @@ import Button from "./Button.astro";
|
|||||||
|
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
// Pohranjujemo originalni sadržaj (ikonu + tekst)
|
|
||||||
const originalContent = btn.innerHTML;
|
const originalContent = btn.innerHTML;
|
||||||
|
|
||||||
// Vizualni feedback (Loading stanje)
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-3"></i> AUTENTIFIKACIJA...';
|
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;
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Prijava i pohrana JWT tokena
|
||||||
const result = await login(email, password);
|
const result = await login(email, password);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// 2. Dohvat profila ulogiranog korisnika
|
||||||
|
const user = await fetchCurrentUser();
|
||||||
|
|
||||||
sessionStorage.setItem('pending_toast', JSON.stringify({
|
sessionStorage.setItem('pending_toast', JSON.stringify({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Pristup odobren. Dobrodošli u sustav.'
|
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 {
|
} else {
|
||||||
// Poziv tvog global-toast sustava
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.showToast?.(result.error || "Pristup odbijen: Neispravni podaci.", "error");
|
window.showToast?.(result.error || "Pristup odbijen: Neispravni podaci.", "error");
|
||||||
|
|
||||||
// Vraćanje gumba u normalu
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = originalContent;
|
btn.innerHTML = originalContent;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// @ts-ignore
|
console.error("Login krah na klijentu:", err);
|
||||||
|
// @ts-ignore
|
||||||
window.showToast?.("Sustav nedostupan. Provjerite vezu.", "error");
|
window.showToast?.("Sustav nedostupan. Provjerite vezu.", "error");
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = originalContent;
|
btn.innerHTML = originalContent;
|
||||||
|
|||||||
@@ -1,59 +1,193 @@
|
|||||||
---
|
---
|
||||||
// src/components/NaslovList.astro
|
// src/components/RadniNalogLista.astro
|
||||||
|
import NaslovList from "./NaslovList.astro";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
naslov: string;
|
limit?: number;
|
||||||
ukupno: number;
|
naslov?: string;
|
||||||
label1: string;
|
prikaziNaslov?: boolean;
|
||||||
count1: number;
|
|
||||||
filter1: string;
|
|
||||||
label2: string;
|
|
||||||
count2: number;
|
|
||||||
filter2: string;
|
|
||||||
label3?: string;
|
|
||||||
count3?: number;
|
|
||||||
filter3?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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]">
|
<div class="space-y-6 group" id="radni-nalozi-sekcija" data-state="loading">
|
||||||
|
{prikaziNaslov && (
|
||||||
<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">
|
<NaslovList
|
||||||
{naslov}
|
naslov={naslov}
|
||||||
</h2>
|
ukupno={0}
|
||||||
|
label1="Planirano"
|
||||||
<div class="flex flex-wrap justify-end items-center gap-2 lg:gap-3">
|
count1={0}
|
||||||
<div
|
filter1="planirano"
|
||||||
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"
|
label2="U radu"
|
||||||
data-filter={filter1}
|
count2={0}
|
||||||
>
|
filter2="u_radu"
|
||||||
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
|
label3="Završeno"
|
||||||
<span class="text-[9px] lg:text-[10px] font-black uppercase text-emerald-700 dark:text-emerald-400 whitespace-nowrap">
|
count3={0}
|
||||||
{label1}: {count1}
|
filter3="zavrseno"
|
||||||
</span>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
<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
|
<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"
|
id="nalozi-loader"
|
||||||
data-filter={filter2}
|
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>
|
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija radnih naloga...
|
||||||
<span class="text-[9px] lg:text-[10px] font-black uppercase text-red-700 dark:text-red-400 whitespace-nowrap">
|
|
||||||
{label2}: {count2}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{label3 && count3 !== undefined && filter3 && (
|
<div id="nalozi-list" class="space-y-0">
|
||||||
<div
|
<div id="nalozi-placeholder" class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest group-data-[state=loaded]:hidden">
|
||||||
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"
|
Nema zapisa za odabrani status
|
||||||
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>
|
</div>
|
||||||
)}
|
</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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
// src/components/Navbar.astro
|
// src/components/Navbar.astro
|
||||||
import site from "../data/site.json";
|
import site from "../data/site.json";
|
||||||
|
import Button from "./Button.astro";
|
||||||
|
|
||||||
const currentPath = Astro.url.pathname;
|
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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between h-20 items-center">
|
<div class="flex justify-between h-20 items-center">
|
||||||
|
|
||||||
<!-- LOGO SEKCIJA -->
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<a href="/" class="flex items-center gap-3 group">
|
<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">
|
<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>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LEBDEĆI MOBILNI IZBORNIK (Transparentan) -->
|
|
||||||
<div
|
<div
|
||||||
id="mobile-menu"
|
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
|
bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl
|
||||||
border border-white/20 dark:border-gray-800/50
|
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"
|
transition-all animate-in fade-in slide-in-from-top-4"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{site.navigation.map((item) => (
|
{site.navigation.map((item) => {
|
||||||
<a
|
// Provjera je li trenutna putanja aktivna (uzima u obzir i potencijalne dinamičke ID pod-rute)
|
||||||
href={item.url}
|
const isCurrent = currentPath === item.url || (item.url === '/operativa/serviseri' && currentPath.includes('/operativa/serviseri'));
|
||||||
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"
|
|
||||||
|
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-plus mr-2"></i> Novi nalog
|
||||||
<i class="fa-solid fa-chevron-right text-[10px] opacity-30 group-hover:translate-x-1 group-hover:opacity-100 transition-all"></i>
|
</Button>
|
||||||
</a>
|
</div>
|
||||||
))}
|
|
||||||
<hr class="my-2 border-gray-100/50 dark:border-gray-800/50" />
|
<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
|
<div class="w-full">
|
||||||
</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function setupNavbar() {
|
import { logout, fetchCurrentUser } from "../lib/api";
|
||||||
|
|
||||||
|
async function setupNavbar() {
|
||||||
const btn = document.getElementById('mobile-menu-button');
|
const btn = document.getElementById('mobile-menu-button');
|
||||||
const menu = document.getElementById('mobile-menu');
|
const menu = document.getElementById('mobile-menu');
|
||||||
|
const authSection = document.getElementById('nav-auth-section');
|
||||||
|
const logoutMobile = document.getElementById('logout-mobile');
|
||||||
|
|
||||||
if (btn && menu) {
|
if (!authSection) return;
|
||||||
// Re-inicijalizacija gumba za Astro View Transitions
|
|
||||||
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
|
|
||||||
btn.parentNode?.replaceChild(newBtn, btn);
|
|
||||||
|
|
||||||
newBtn.addEventListener('click', (e) => {
|
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||||
e.stopPropagation();
|
const stanjeAutentifikacije = token ? 'authenticated' : 'anonymous';
|
||||||
menu.classList.toggle('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Zatvori menu na klik izvan njega
|
// 1. Postavljamo stanje u DOM (odmah prebacuje vidljivost gumbova Prijava / Menu)
|
||||||
document.addEventListener('click', (e) => {
|
authSection.setAttribute('data-state', stanjeAutentifikacije);
|
||||||
if (!menu.contains(e.target as Node) && !newBtn.contains(e.target as Node)) {
|
authSection.classList.remove('hidden');
|
||||||
menu.classList.add('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
|
// Okidanje na inicijalno učitavanje i Astro after-swap
|
||||||
const logout = () => {
|
|
||||||
sessionStorage.removeItem('user_session');
|
|
||||||
window.location.href = '/login';
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('logout-btn')?.addEventListener('click', logout);
|
|
||||||
document.getElementById('logout-mobile')?.addEventListener('click', logout);
|
|
||||||
|
|
||||||
setupNavbar();
|
setupNavbar();
|
||||||
document.addEventListener('astro:after-swap', setupNavbar);
|
document.addEventListener('astro:after-swap', setupNavbar);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
161
001.FRONTEND/src/components/PutniNalogLista.astro
Normal file
161
001.FRONTEND/src/components/PutniNalogLista.astro
Normal 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>
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
---
|
---
|
||||||
// src/components/RadniNalogLista.astro
|
// src/components/RadniNalogLista.astro
|
||||||
import { fetchDashboardData } from "../lib/api";
|
|
||||||
import { getStatusColorClass, formatStatus } from "../utils/ui";
|
|
||||||
import NaslovList from "./NaslovList.astro";
|
import NaslovList from "./NaslovList.astro";
|
||||||
import GenericKarticaItem from "./GenericKarticaItem.astro";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -16,80 +13,190 @@ const {
|
|||||||
naslov = "Zadnje aktivnosti",
|
naslov = "Zadnje aktivnosti",
|
||||||
prikaziNaslov = true,
|
prikaziNaslov = true,
|
||||||
} = Astro.props;
|
} = 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 && (
|
{prikaziNaslov && (
|
||||||
<NaslovList
|
<NaslovList
|
||||||
naslov={prikazaniNaslov}
|
naslov={naslov}
|
||||||
ukupno={filtriraniNalozi.length}
|
sinkronizirano={false} label1="Planirano" count1={0} filter1="planirano"
|
||||||
label1="Planirano"
|
label2="U radu" count2={0} filter2="u_radu"
|
||||||
count1={planiranoCount} // Sada će ispravno prikazati stvarni broj
|
|
||||||
filter1="planirano"
|
|
||||||
label2="U radu"
|
|
||||||
count2={uRaduCount} // Sada će ispravno prikazati stvarni broj
|
|
||||||
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 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 ? (
|
<div
|
||||||
prikazaniNalozi.map((n) => (
|
id="nalozi-loader"
|
||||||
<GenericKarticaItem
|
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"
|
||||||
href={`/operativa/radni-nalozi/${n.id}`}
|
>
|
||||||
status={n.status?.toLowerCase()} // Šaljemo ujednačeni lowercase u podkomponente
|
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija radnih naloga...
|
||||||
statusBojaClass={getStatusColorClass(n.status?.toLowerCase())}
|
</div>
|
||||||
ikona={n.status?.toLowerCase() === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice'}
|
|
||||||
naslov={`#${n.broj_naloga}`}
|
<div id="nalozi-list" class="space-y-0">
|
||||||
subNaslov={formatStatus(n.status?.toLowerCase())}
|
<div id="nalozi-placeholder" class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest group-data-[state=loaded]:hidden">
|
||||||
metaTekst={`${n.klijent_naziv || 'Nepoznat klijent'} | ${n.vozilo_naziv || 'Bez vozila'} | ${n.stroj_naziv || 'Bez stroja'}`}
|
Nema zapisa za odabrani status
|
||||||
statusPrikaz={formatStatus(n.status?.toLowerCase())}
|
</div>
|
||||||
bojaTeme={n.boja_teme || 'blue'}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div class="p-20 text-center uppercase font-black opacity-20 italic">
|
|
||||||
Nema zapisa za odabrani status
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<span id="rn-bridge" data-limit={limit} data-naslov={naslov}></span>
|
||||||
// Ponovno inicijaliziramo klijentsku skriptu za klikove na kartice filtera
|
|
||||||
import { initFilters } from "../scripts/filters.js";
|
|
||||||
|
|
||||||
// Prvo pokretanje
|
|
||||||
initFilters();
|
|
||||||
|
|
||||||
// Podrška za Astro View Transitions
|
<script>
|
||||||
document.addEventListener('astro:after-swap', initFilters);
|
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>
|
</script>
|
||||||
242
001.FRONTEND/src/components/ServiserTerminal.astro
Normal file
242
001.FRONTEND/src/components/ServiserTerminal.astro
Normal 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>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"icon": "fa-address-book",
|
"icon": "fa-address-book",
|
||||||
"welcomeHeaderDisplay": true,
|
"welcomeHeaderDisplay": true,
|
||||||
"welcomeHeaderTextH1": "Pregled",
|
"welcomeHeaderTextH1": "Pregled",
|
||||||
"welcomeHeaderTextH1dodatno": "klijenata",
|
"welcomeHeaderTextH1dodatno": " klijenata",
|
||||||
"welcomeHeaderPodnaslov": "Upravljanje bazom korisnika i partnera",
|
"welcomeHeaderPodnaslov": "Upravljanje bazom korisnika i partnera",
|
||||||
"welcomeHeaderPovratniURL": "/"
|
"welcomeHeaderPovratniURL": "/"
|
||||||
},
|
},
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"icon": "fa-calendar-days",
|
"icon": "fa-calendar-days",
|
||||||
"welcomeHeaderDisplay": true,
|
"welcomeHeaderDisplay": true,
|
||||||
"welcomeHeaderTextH1": "Kalendar",
|
"welcomeHeaderTextH1": "Kalendar",
|
||||||
"welcomeHeaderTextH1dodatno": "događaja",
|
"welcomeHeaderTextH1dodatno": " događaja",
|
||||||
"welcomeHeaderPodnaslov": "Pregled operacija u stvarnom vremenu",
|
"welcomeHeaderPodnaslov": "Pregled operacija u stvarnom vremenu",
|
||||||
"welcomeHeaderPovratniURL": "/"
|
"welcomeHeaderPovratniURL": "/"
|
||||||
},
|
},
|
||||||
@@ -87,6 +87,16 @@
|
|||||||
"welcomeHeaderTextH1dodatno": " Radni nalog",
|
"welcomeHeaderTextH1dodatno": " Radni nalog",
|
||||||
"welcomeHeaderPodnaslov": "Otvaranje novog servisnog ili radnog naloga u sustavu",
|
"welcomeHeaderPodnaslov": "Otvaranje novog servisnog ili radnog naloga u sustavu",
|
||||||
"welcomeHeaderPovratniURL": "/operativa/radni-nalozi"
|
"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": {
|
"api_endpoints": {
|
||||||
|
|||||||
@@ -4,27 +4,38 @@
|
|||||||
const RAW_BASE = import.meta.env.PUBLIC_API_URL;
|
const RAW_BASE = import.meta.env.PUBLIC_API_URL;
|
||||||
const API_BASE = RAW_BASE.endsWith('/') ? RAW_BASE : `${RAW_BASE}/`;
|
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 = {}) {
|
function getAuthHeaders(bodyData = {}, ssrToken = null) {
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
|
||||||
const headers = {};
|
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) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`; // Usklađeno sa Simple JWT standardom
|
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;
|
const isFormData = bodyData instanceof FormData;
|
||||||
|
|
||||||
// Ako NIJE FormData, šaljemo JSON
|
// Ako NIJE FormData, šaljemo standardni JSON content type
|
||||||
if (!isFormData) {
|
if (!isFormData) {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralizirana obrada odgovora s Toast podrškom
|
* Centralizirana obrada odgovora s Toast podrškom
|
||||||
*/
|
*/
|
||||||
@@ -69,9 +80,13 @@ async function handleResponse(res) {
|
|||||||
|
|
||||||
// --- RUTE ---
|
// --- RUTE ---
|
||||||
export const routes = {
|
export const routes = {
|
||||||
// Autentifikacija (Maknute početne kose crte!)
|
// Autentifikacija
|
||||||
login: () => 'token/',
|
login: () => 'token/',
|
||||||
trenutniKorisnik: () => 'users/me/',
|
trenutniKorisnik: () => 'users/me/',
|
||||||
|
|
||||||
|
// Korisnici i operativni tehničari
|
||||||
|
serviseriSvi: () => 'users/',
|
||||||
|
serviserTerminal: (id) => `users/${id}/terminal/`,
|
||||||
|
|
||||||
// Radni nalozi
|
// Radni nalozi
|
||||||
radniNalozi: (params = {}) => {
|
radniNalozi: (params = {}) => {
|
||||||
@@ -109,22 +124,72 @@ export const routes = {
|
|||||||
// --- API METODE ---
|
// --- 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 {
|
try {
|
||||||
const url = `${API_BASE}${routes.trenutniKorisnik()}`;
|
const url = `${API_BASE}${routes.trenutniKorisnik()}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'GET',
|
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) {
|
} catch (e) {
|
||||||
console.error("fetchCurrentUser Failure:", e);
|
console.error("fetchCurrentUser Failure:", e);
|
||||||
return null;
|
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)
|
* Dohvat i filtriranje radnih naloga (za tablice i liste u operativi)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,19 +8,14 @@ import StatsGrid from "../components/StatsGrid.astro";
|
|||||||
import Kalendar from "../components/Kalendar/Kalendar.astro";
|
import Kalendar from "../components/Kalendar/Kalendar.astro";
|
||||||
|
|
||||||
// API i Centralizirani Utils iz api.js
|
// 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)
|
// 1. Dohvat podataka s backenda (Samo bazični dashboard podaci koji se mogu poslužiti asinkrono)
|
||||||
const [data, user] = await Promise.all([
|
const data = await fetchDashboardData();
|
||||||
fetchDashboardData(),
|
|
||||||
fetchCurrentUser()
|
|
||||||
]);
|
|
||||||
|
|
||||||
const nalozi = data?.nalozi || [];
|
const nalozi = data?.nalozi || [];
|
||||||
const imeKorisnika = user?.first_name || "Kolega";
|
|
||||||
|
|
||||||
// 2. POPRAVLJENO: Kalkulacija statistike tolerantna na velika/mala slova (.toLowerCase())
|
// 2. Kalkulacija statistike tolerantna na velika/mala slova (.toLowerCase())
|
||||||
// Osiguravamo da se podaci iz baze (PLANIRANO, U_RADU) savršeno upare sa StatsGrid filterima
|
|
||||||
const naloziURadu = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
|
const naloziURadu = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
|
||||||
const planiraniNalozi = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
|
const planiraniNalozi = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
|
||||||
|
|
||||||
@@ -33,7 +28,7 @@ const zavrseniNalozi = nalozi.filter(n => {
|
|||||||
<Layout title="Dashboard | ServisLog">
|
<Layout title="Dashboard | ServisLog">
|
||||||
<div class="w-full space-y-5">
|
<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">
|
<div class="grid grid-cols-3 gap-2 sm:gap-6 px-2">
|
||||||
<StatsGrid
|
<StatsGrid
|
||||||
@@ -84,10 +79,19 @@ const zavrseniNalozi = nalozi.filter(n => {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { initFilters } from "../scripts/filters.js";
|
import { initFilters } from "../scripts/filters.js";
|
||||||
|
import { provjeriPristupStranici } from "../utils/auth.js";
|
||||||
|
|
||||||
// Inicijalizacija filtera za StatsGrid klikove na klijentu
|
// 🚀 POKREĆEMO SIGURNOSNI KLIJENTSKI GUARD:
|
||||||
initFilters();
|
// 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)
|
// Pokreni odmah pri učitavanju u preglednik
|
||||||
document.addEventListener('astro:after-swap', initFilters);
|
inicijalizirajStranicu();
|
||||||
|
|
||||||
|
// Re-inicijalizacija kod klijentske navigacije (Astro View Transitions)
|
||||||
|
document.addEventListener('astro:after-swap', inicijalizirajStranicu);
|
||||||
</script>
|
</script>
|
||||||
@@ -3,23 +3,31 @@
|
|||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import WelcomeHeader from "../components/WelcomeHeader.astro";
|
import WelcomeHeader from "../components/WelcomeHeader.astro";
|
||||||
import Kalendar from "../components/Kalendar/Kalendar.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">
|
<Layout title="Kalendar | ServisLog">
|
||||||
<div class="w-full space-y-10">
|
<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">
|
<div class="grid grid-cols-1 gap-2 sm:gap-6 px-2">
|
||||||
<Kalendar />
|
<Kalendar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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>
|
||||||
51
001.FRONTEND/src/pages/operativa/serviseri/[id].astro
Normal file
51
001.FRONTEND/src/pages/operativa/serviseri/[id].astro
Normal 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>
|
||||||
136
001.FRONTEND/src/pages/operativa/serviseri/index.astro
Normal file
136
001.FRONTEND/src/pages/operativa/serviseri/index.astro
Normal 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>
|
||||||
61
001.FRONTEND/src/utils/auth.js
Normal file
61
001.FRONTEND/src/utils/auth.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
biljeske/LOCALSTORAGE_UI_OBAVIJESTI.MD
Normal file
400
biljeske/LOCALSTORAGE_UI_OBAVIJESTI.MD
Normal 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.
|
||||||
160
biljeske/LOKALIZACIJA_CDN_RESURSA.md
Normal file
160
biljeske/LOKALIZACIJA_CDN_RESURSA.md
Normal 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">
|
||||||
|
© {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;
|
||||||
|
}
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user