Compare commits
2 Commits
e308cd8d13
...
668195e642
| Author | SHA1 | Date | |
|---|---|---|---|
| 668195e642 | |||
| 80f9ba3879 |
@@ -125,6 +125,15 @@ SIMPLE_JWT = {
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
|
||||
|
||||
# 2. CSRF_TRUSTED_ORIGINS
|
||||
# Primjer: https://app.tvojadomena.hr,https://api.tvojadomena.hr
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
origin.strip()
|
||||
for origin in os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://127.0.0.1:4321,http://localhost:4321').split(',')
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
# CORS POSTAVKE - POPRAVLJENI ZAREZI I FORMALNI ORIGINI
|
||||
if DEBUG:
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -17,6 +17,21 @@ class PutniNalogMinimalSerializer(serializers.ModelSerializer):
|
||||
model = PutniNalog
|
||||
fields = ['id', 'broj_naloga', 'status', 'pocetna_km']
|
||||
|
||||
# --- NOVI STANDARDNI PUTNI NALOG SERIALIZER (Za rješavanje ImportError-a) ---
|
||||
class PutniNalogSerializer(serializers.ModelSerializer):
|
||||
vozilo_detalji = VoziloListaSerializer(source='vozilo', read_only=True)
|
||||
korisnik_ime = serializers.CharField(source='korisnik.get_full_name', read_only=True)
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PutniNalog
|
||||
fields = [
|
||||
'id', 'broj_naloga', 'vozilo', 'vozilo_detalji', 'korisnik', 'korisnik_ime',
|
||||
'relacija', 'mjesto_odredista', 'pocetna_km', 'zavrsna_km', 'status',
|
||||
'status_display', 'vrijeme_polaska', 'vrijeme_povratka'
|
||||
]
|
||||
read_only_fields = ['id', 'broj_naloga', 'vrijeme_polaska']
|
||||
|
||||
# --- 1. LISTA SERIALIZER ---
|
||||
class RadniNalogListaSerializer(serializers.ModelSerializer):
|
||||
klijent_naziv = serializers.CharField(source='klijent.naziv', read_only=True)
|
||||
|
||||
@@ -3,11 +3,21 @@ from django.contrib.auth.admin import UserAdmin
|
||||
from .models import CustomUser
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
model = CustomUser
|
||||
# Definiramo koja polja se vide u admin listi i formama
|
||||
list_display = ['email', 'username', 'first_name', 'last_name', 'is_staff', 'is_serviser']
|
||||
# 🚀 POPRAVLJENO: Zamijenjen 'is_serviser' s 'uloga' u stupcima tablice
|
||||
list_display = ['email', 'first_name', 'last_name', 'telefon', 'oib', 'uloga', 'is_staff']
|
||||
|
||||
# Ako filtriraš korisnike na desnoj strani admina, ažuriraj i to:
|
||||
list_filter = ['uloga', 'is_staff', 'is_active']
|
||||
|
||||
# 🚀 POPRAVLJENO: Dodavanje polja u sekciju za uređivanje postojećeg korisnika
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
('Dodatni podaci', {'fields': ('telefon', 'oib', 'is_serviser')}),
|
||||
('Dodatni podaci tvrtke', {'fields': ('telefon', 'oib', 'uloga')}),
|
||||
)
|
||||
|
||||
# 🚀 POPRAVLJENO: Dodavanje polja u formu za kreiranje novog korisnika
|
||||
add_fieldsets = UserAdmin.add_fieldsets + (
|
||||
('Dodatni podaci tvrtke', {'fields': ('telefon', 'oib', 'uloga')}),
|
||||
)
|
||||
|
||||
# Registracija modela i tvoje prilagođene admin klase
|
||||
admin.site.register(CustomUser, CustomUserAdmin)
|
||||
@@ -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
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
class Role(models.TextChoices):
|
||||
SERVISER = 'SERVISER', 'Serviser'
|
||||
PRODAJA = 'PRODAJA', 'Prodaja / Operativa'
|
||||
KNJIGOVODSTVO = 'KNJIGOVODSTVO', 'Knjigovodstvo i financije'
|
||||
ADMIN = 'ADMIN', 'Administrator'
|
||||
|
||||
# Email koristimo za login, pa mora biti jedinstven
|
||||
email = models.EmailField(unique=True)
|
||||
|
||||
# Dodatna polja za tvoju tvrtku
|
||||
telefon = models.CharField(max_length=20, blank=True)
|
||||
oib = models.CharField(max_length=11, blank=True, null=True)
|
||||
is_serviser = models.BooleanField(default=False) # Razlikovanje uloga
|
||||
# 🚀 ZAMJENA: Umjesto is_serviser, uvodimo jedno polje za sve uloge
|
||||
uloga = models.CharField(
|
||||
max_length=20,
|
||||
choices=Role.choices,
|
||||
default=Role.SERVISER,
|
||||
help_text="Glavna operativna uloga korisnika u ERP sustavu"
|
||||
)
|
||||
|
||||
# Govorimo Djangu da koristi email umjesto username-a
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username', 'first_name', 'last_name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name} ({self.email})"
|
||||
return f"{self.first_name} {self.last_name} ({self.email}) - {self.get_uloga_display()}"
|
||||
@@ -1,7 +1,96 @@
|
||||
from rest_framework import serializers
|
||||
from .models import CustomUser
|
||||
from fleet.models import Vozilo
|
||||
from operations.models import RadniNalog, PutniNalog
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
# Prosljeđujemo i ljudski čitljiv naziv (npr. "Knjigovodstvo i financije")
|
||||
uloga_prikaz = serializers.CharField(source='get_uloga_display', read_only=True)
|
||||
|
||||
class UserMeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser']
|
||||
fields = ['id', 'first_name', 'last_name', 'email', 'telefon', 'oib', 'uloga', 'uloga_prikaz']
|
||||
|
||||
# --- POMOĆNI LIGHTWEIGHT SERIALIZERI (Sprečavaju prevelik JSON payload) ---
|
||||
|
||||
class ServiserVoziloSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Vozilo
|
||||
# 🚀 POPRAVLJENO: Izbačena nepostojeća polja (marka, model, tip).
|
||||
# Usklađeno s tvojim stvarnim poljima iz /fleet/models.py
|
||||
fields = [
|
||||
'id',
|
||||
'naziv',
|
||||
'registracija',
|
||||
'trenutni_kilometri',
|
||||
'status'
|
||||
]
|
||||
|
||||
class ServiserRadniNalogSerializer(serializers.ModelSerializer):
|
||||
# Prikazujemo nazive umjesto ID-jeva radi lakšeg klijentskog ispisa na terminalu
|
||||
kupac_naziv = serializers.CharField(source='klijent.naziv', read_only=True)
|
||||
stroj_naziv = serializers.CharField(source='stroj.naziv', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RadniNalog
|
||||
# 🚀 POPRAVLJENO: 'prioritet' je izbačen jer ne postoji na modelu RadniNalog.
|
||||
# Dodan je 'opis_kvara' koji ti može zatrebati na klijentskim karticama!
|
||||
fields = [
|
||||
'id',
|
||||
'broj_naloga',
|
||||
'status',
|
||||
'opis_kvara',
|
||||
'datum_kreiranja',
|
||||
'kupac_naziv',
|
||||
'stroj_naziv'
|
||||
]
|
||||
|
||||
class ServiserPutniNalogSerializer(serializers.ModelSerializer):
|
||||
vozilo_registracija = serializers.CharField(source='vozilo.registracija', read_only=True)
|
||||
|
||||
# 🚀 Budući da jedan putni nalog može imati više radnih naloga (ili nijedan ako tek kreće na put),
|
||||
# koristimo SerializerMethodField za siguran dohvat brojeva dokumenata bez rušenja
|
||||
brojevi_radnih_naloga = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PutniNalog
|
||||
# 🚀 USKLAĐENO s tvojim točnim poljima iz modela PutniNalog:
|
||||
fields = [
|
||||
'id',
|
||||
'broj_naloga',
|
||||
'datum_izdavanja',
|
||||
'status',
|
||||
'relacija',
|
||||
'mjesto_odredista',
|
||||
'pocetna_km',
|
||||
'zavrsna_km',
|
||||
'vozilo_registracija',
|
||||
'brojevi_radnih_naloga'
|
||||
]
|
||||
|
||||
def get_brojevi_radnih_naloga(self, obj):
|
||||
# Dohvaćamo sve povezane radne naloge preko related_name='radni_nalozi'
|
||||
nalozi = obj.radni_nalozi.all()
|
||||
if nalozi.exists():
|
||||
# Vraćamo npr. "RN-2026-0001, RN-2026-0002" ako ih ima više
|
||||
return ", ".join([rn.broj_naloga for rn in nalozi if rn.broj_naloga])
|
||||
return "Nema povezanih RN"
|
||||
|
||||
|
||||
# --- GLAVNI OPERATIVNI SERIALIZER ZA TERMINAL SERVISERA ---
|
||||
|
||||
class ServiserTerminalSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer koji objedinjuje sve podatke servisera i njegove dodijeljene resurse.
|
||||
Ne nasljeđuje ModelSerializer jer serijalizira rječnik (dict) dobiven iz service sloja.
|
||||
"""
|
||||
id = serializers.IntegerField(source='serviser.id')
|
||||
email = serializers.EmailField(source='serviser.email')
|
||||
first_name = serializers.CharField(source='serviser.first_name')
|
||||
last_name = serializers.CharField(source='serviser.last_name')
|
||||
uloga = serializers.CharField(source='serviser.uloga')
|
||||
|
||||
# 🚀 Ugniježđeni podaci koji se pune kroz asocijativni rječnik iz services.py
|
||||
radni_nalozi = ServiserRadniNalogSerializer(many=True)
|
||||
putni_nalozi = ServiserPutniNalogSerializer(many=True)
|
||||
vozila = ServiserVoziloSerializer(many=True)
|
||||
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.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.conf import settings
|
||||
from .models import CustomUser
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework import serializers
|
||||
from .models import CustomUser
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from .models import CustomUser
|
||||
from .services import dohvati_operativne_podatke_servisera
|
||||
from .serializers import ServiserTerminalSerializer
|
||||
|
||||
# 🚀 1. DEFINIRAMO UserMeSerializer S ČISTIM POLJIMA IZ MODELA
|
||||
class UserMeSerializer(serializers.ModelSerializer):
|
||||
uloga = serializers.SerializerMethodField()
|
||||
is_serviser = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser']
|
||||
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser', 'uloga']
|
||||
|
||||
def get_is_serviser(self, obj):
|
||||
uloga_str = getattr(obj, 'uloga', '')
|
||||
return str(uloga_str).upper().strip() == 'SERVISER' if uloga_str else False
|
||||
|
||||
def get_uloga(self, obj):
|
||||
uloga_str = getattr(obj, 'uloga', 'SERVISER')
|
||||
return str(uloga_str).upper().strip()
|
||||
|
||||
|
||||
# 🚀 2. AKTIVNI VIEWSET S BACKEND OSIGURAČIMA
|
||||
class UserViewSet(viewsets.ViewSet):
|
||||
# Akcija 'me' bit će dostupna na /api/users/me/
|
||||
@action(detail=False, methods=['get'])
|
||||
authentication_classes = [JWTAuthentication]
|
||||
# 🎯 POPRAVAK 1: Globalno zaključavamo ViewSet, samo ulogirani korisnici prolaze
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='me')
|
||||
def me(self, request):
|
||||
# 1. Provjera je li korisnik stvarno logiran (ima validan token)
|
||||
if not request.user.is_authenticated:
|
||||
# Ova provjera ostaje kao oporavak u slučaju da JWT autentifikacija propusti prazan objekt
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return Response(
|
||||
{"detail": "Niste prijavljeni. U DEBUG modu pristup je dozvoljen, ali nema podataka za AnonymousUser."},
|
||||
status=status.HTTP_200_OK # Možeš vratiti 200 s praznim podacima ili 401
|
||||
{
|
||||
"detail": "Aktivna sesija nije pronađena. Pristup neautoriziran.",
|
||||
"code": "token_not_valid"
|
||||
},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# 2. Ako je logiran, normalno serijaliziraj
|
||||
serializer = UserMeSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
try:
|
||||
cisti_korisnik = CustomUser.objects.get(id=request.user.id)
|
||||
serializer = UserMeSerializer(cisti_korisnik)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except CustomUser.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Korisnik ne postoji u bazi podataka."},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Kritični krah unutar api/users/me: {str(e)}")
|
||||
return Response(
|
||||
{"detail": f"Interna greška poslužitelja: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='terminal')
|
||||
def terminal_podaci(self, request, pk=None):
|
||||
"""
|
||||
Dohvaća sve operativne resurse za specifičnog servisera na ruti:
|
||||
GET /api/users/<id>/terminal/
|
||||
"""
|
||||
# 🎯 POPRAVAK 2: BACKEND ZAŠTITA OD NJUŠKANJA URL-ova
|
||||
trenutni_korisnik = request.user
|
||||
trenutna_uloga = str(getattr(trenutni_korisnik, 'uloga', '')).upper().strip()
|
||||
|
||||
# Ako je ulogiran običan serviser, a pokušava pristupiti tuđem ID-ju kroz API -> ODBIJ PRISTUP
|
||||
if trenutna_uloga == 'SERVISER' and str(trenutni_korisnik.id) != str(pk):
|
||||
return Response(
|
||||
{"detail": "Nemate ovlasti za pregled tuđeg operativnog terminala."},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Okidamo biznis logiku iz services.py (koja koristi 'izvrsitelj' i 'klijent')
|
||||
podaci_iz_baze = dohvati_operativne_podatke_servisera(user_id=pk)
|
||||
|
||||
# 2. Prosljeđujemo rječnik u objedinjeni serializer
|
||||
serializer = ServiserTerminalSerializer(podaci_iz_baze)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except PermissionDenied as pd_err:
|
||||
return Response({"detail": str(pd_err)}, status=status.HTTP_403_FORBIDDEN)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"detail": f"Greška pri obradi operativnih podataka: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
@@ -10,13 +10,19 @@ export default defineConfig({
|
||||
}),
|
||||
|
||||
server: {
|
||||
host: true,
|
||||
host: '0.0.0.0',
|
||||
port: 4321,
|
||||
},
|
||||
|
||||
image: {
|
||||
// Dodaj domene s kojih Astro smije povlačiti i optimizirati slike
|
||||
domains: ['localhost', '127.0.0.1','v003-backend.captain.mitteworkspace.cloud'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'frontend-operativa.local.mitteworkspace.cloud',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
build: {
|
||||
@@ -24,11 +30,22 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
vite: {
|
||||
build: {
|
||||
// Smanji intenzitet optimizacije tijekom dev-a
|
||||
minify: false,
|
||||
cssMinify: false,
|
||||
},
|
||||
plugins: [tailwindcss()],
|
||||
// KLJUČNO ZA TAILWIND v4 + NODE standalone SSR:
|
||||
ssr: {
|
||||
// Prisiljava Vite da uključi Tailwind v4 stilove u serverski bundle
|
||||
noExternal: ['tailwindcss', '@tailwindcss/vite']
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/node_modules/**', '**/dist/**'],
|
||||
},
|
||||
allowedHosts: ['.mitteworkspace.cloud']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
469
001.FRONTEND/package-lock.json
generated
469
001.FRONTEND/package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.6",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"astro": "^6.1.9",
|
||||
"astro": "^6.3.7",
|
||||
"flowbite": "^4.0.1",
|
||||
"photoswipe": "^5.4.4",
|
||||
"tailwindcss": "^4.2.4"
|
||||
@@ -203,17 +203,6 @@
|
||||
"node": ">= 20.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
@@ -224,16 +213,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
@@ -1161,40 +1140,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oslojs/encoding": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.132.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
|
||||
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -1205,270 +1156,6 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
|
||||
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
|
||||
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "15.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
|
||||
@@ -2210,16 +1897,6 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
@@ -2414,80 +2091,6 @@
|
||||
"sharp": "^0.34.0"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/vite": {
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -5304,40 +4907,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.132.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-x64": "1.0.2",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.2",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.2",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.2",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.2",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.2",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
@@ -5643,9 +5212,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
||||
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -6013,17 +5582,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.2",
|
||||
"tinyglobby": "^0.2.16"
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
@@ -6039,10 +5608,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
@@ -6055,18 +5623,15 @@
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"name": "poslovanje",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"overrides": {
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
@@ -10,12 +13,12 @@
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"start": "node ./dist/server/entry.mjs"
|
||||
"start": "NODE_OPTIONS=--max-old-space-size=4096 npm run dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.6",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"astro": "^6.1.9",
|
||||
"astro": "^6.3.7",
|
||||
"flowbite": "^4.0.1",
|
||||
"photoswipe": "^5.4.4",
|
||||
"tailwindcss": "^4.2.4"
|
||||
|
||||
9
001.FRONTEND/public/css/all.min.css
vendored
Normal file
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>
|
||||
|
||||
<script>
|
||||
import { login } from "../lib/api";
|
||||
import { login, fetchCurrentUser } from "../lib/api";
|
||||
// 🚀 Ponovno uvozimo centraliziranu funkciju za rute
|
||||
import { dohvatiRutuPoUlozi } from "../utils/auth";
|
||||
|
||||
const loginForm = document.querySelector('#login-form') as HTMLFormElement;
|
||||
const btn = document.querySelector('#login-btn') as HTMLButtonElement;
|
||||
@@ -64,10 +66,8 @@ import Button from "./Button.astro";
|
||||
|
||||
if (!btn) return;
|
||||
|
||||
// Pohranjujemo originalni sadržaj (ikonu + tekst)
|
||||
const originalContent = btn.innerHTML;
|
||||
|
||||
// Vizualni feedback (Loading stanje)
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-3"></i> AUTENTIFIKACIJA...';
|
||||
|
||||
@@ -76,25 +76,32 @@ import Button from "./Button.astro";
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
try {
|
||||
// 1. Prijava i pohrana JWT tokena
|
||||
const result = await login(email, password);
|
||||
|
||||
if (result.success) {
|
||||
// 2. Dohvat profila ulogiranog korisnika
|
||||
const user = await fetchCurrentUser();
|
||||
|
||||
sessionStorage.setItem('pending_toast', JSON.stringify({
|
||||
type: 'success',
|
||||
message: 'Pristup odobren. Dobrodošli u sustav.'
|
||||
}));
|
||||
window.location.href = '/';
|
||||
|
||||
// 3. 🚀 PREUSMJERAVANJE PREKO POMOĆNE FUNKCIJE:
|
||||
// Namjerno NE prosljeđujemo user.id jer želimo da serviser sleti na opću /operativa/serviseri stazu
|
||||
const ciljanaRuta = dohvatiRutuPoUlozi(user?.uloga);
|
||||
|
||||
window.location.href = ciljanaRuta;
|
||||
} else {
|
||||
// Poziv tvog global-toast sustava
|
||||
// @ts-ignore
|
||||
window.showToast?.(result.error || "Pristup odbijen: Neispravni podaci.", "error");
|
||||
|
||||
// Vraćanje gumba u normalu
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalContent;
|
||||
}
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
console.error("Login krah na klijentu:", err);
|
||||
// @ts-ignore
|
||||
window.showToast?.("Sustav nedostupan. Provjerite vezu.", "error");
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalContent;
|
||||
|
||||
@@ -1,59 +1,193 @@
|
||||
---
|
||||
// src/components/NaslovList.astro
|
||||
// src/components/RadniNalogLista.astro
|
||||
import NaslovList from "./NaslovList.astro";
|
||||
|
||||
interface Props {
|
||||
naslov: string;
|
||||
ukupno: number;
|
||||
label1: string;
|
||||
count1: number;
|
||||
filter1: string;
|
||||
label2: string;
|
||||
count2: number;
|
||||
filter2: string;
|
||||
label3?: string;
|
||||
count3?: number;
|
||||
filter3?: string;
|
||||
limit?: number;
|
||||
naslov?: string;
|
||||
prikaziNaslov?: boolean;
|
||||
}
|
||||
|
||||
const { naslov, ukupno, label1, count1, filter1, label2, count2, filter2, label3, count3, filter3 } = Astro.props;
|
||||
const {
|
||||
limit = 0,
|
||||
naslov = "Zadnje aktivnosti",
|
||||
prikaziNaslov = true,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-row justify-between items-center w-full min-h-[40px]">
|
||||
|
||||
<h2 id="filter-title" class="p-0 m-0 text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight italic leading-none">
|
||||
{naslov}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-wrap justify-end items-center gap-2 lg:gap-3">
|
||||
<div
|
||||
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border border-emerald-100 dark:border-emerald-800/50 cursor-pointer transition-all active:scale-95 hover:border-emerald-400"
|
||||
data-filter={filter1}
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
|
||||
<span class="text-[9px] lg:text-[10px] font-black uppercase text-emerald-700 dark:text-emerald-400 whitespace-nowrap">
|
||||
{label1}: {count1}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-6 group" id="radni-nalozi-sekcija" data-state="loading">
|
||||
{prikaziNaslov && (
|
||||
<NaslovList
|
||||
naslov={naslov}
|
||||
ukupno={0}
|
||||
label1="Planirano"
|
||||
count1={0}
|
||||
filter1="planirano"
|
||||
label2="U radu"
|
||||
count2={0}
|
||||
filter2="u_radu"
|
||||
label3="Završeno"
|
||||
count3={0}
|
||||
filter3="zavrseno"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
|
||||
|
||||
<div
|
||||
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-100 dark:border-red-800/50 cursor-pointer transition-all active:scale-95 hover:border-red-400"
|
||||
data-filter={filter2}
|
||||
id="nalozi-loader"
|
||||
class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse group-data-[state=loaded]:hidden group-data-[state=error]:hidden"
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full bg-red-500 animate-pulse"></div>
|
||||
<span class="text-[9px] lg:text-[10px] font-black uppercase text-red-700 dark:text-red-400 whitespace-nowrap">
|
||||
{label2}: {count2}
|
||||
</span>
|
||||
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija radnih naloga...
|
||||
</div>
|
||||
|
||||
{label3 && count3 !== undefined && filter3 && (
|
||||
<div
|
||||
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-900/20 rounded-xl border border-gray-100 dark:border-gray-800/50 cursor-pointer transition-all active:scale-95 hover:border-gray-400"
|
||||
data-filter={filter3}
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full bg-gray-400"></div>
|
||||
<span class="text-[9px] lg:text-[10px] font-black uppercase text-gray-700 dark:text-gray-400 whitespace-nowrap">
|
||||
{label3}: {count3}
|
||||
</span>
|
||||
|
||||
<div id="nalozi-list" class="space-y-0">
|
||||
<div id="nalozi-placeholder" class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest group-data-[state=loaded]:hidden">
|
||||
Nema zapisa za odabrani status
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span id="rn-bridge" data-limit={limit} data-naslov={naslov}></span>
|
||||
|
||||
<script>
|
||||
import { fetchCurrentUser, fetchDashboardData, fetchServiserTerminalData } from "../lib/api";
|
||||
import { getStatusColorClass, formatStatus } from "../utils/ui";
|
||||
import { initFilters } from "../scripts/filters.js";
|
||||
|
||||
async function renderirajRadneNalogeKlijentski() {
|
||||
const container = document.getElementById('nalozi-list');
|
||||
const sekcija = document.getElementById('radni-nalozi-sekcija');
|
||||
const bridge = document.getElementById('rn-bridge');
|
||||
|
||||
if (!container || !sekcija || !bridge) return;
|
||||
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||
|
||||
if (!token) {
|
||||
sekcija.setAttribute('data-state', 'error');
|
||||
container.innerHTML = `
|
||||
<div class="p-20 text-center flex flex-col items-center justify-center gap-3">
|
||||
<div class="w-12 h-12 bg-red-600/10 text-red-600 rounded-xl flex items-center justify-center text-lg mb-2">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
</div>
|
||||
<h4 class="text-xs font-black uppercase tracking-widest text-red-600">Pristup odbijen</h4>
|
||||
<p class="text-[10px] text-gray-400 font-bold uppercase tracking-wider italic">Niste autorizirani za pregled operativnih naloga flote.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = parseInt(bridge.getAttribute('data-limit') || '0');
|
||||
const originalNaslov = bridge.getAttribute('data-naslov') || 'Zadnje aktivnosti';
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const statusFilter = urlParams.get('status')?.toLowerCase();
|
||||
|
||||
try {
|
||||
sekcija.setAttribute('data-state', 'loading');
|
||||
|
||||
const user = await fetchCurrentUser();
|
||||
const ulogaGore = user?.uloga ? String(user.uloga).toUpperCase().trim() : '';
|
||||
|
||||
let nalozi = [];
|
||||
|
||||
if (ulogaGore === 'SERVISER' && user?.id) {
|
||||
const terminalData = await fetchServiserTerminalData(user.id);
|
||||
nalozi = terminalData?.radni_nalozi || [];
|
||||
} else {
|
||||
const data = await fetchDashboardData();
|
||||
nalozi = data?.nalozi || [];
|
||||
}
|
||||
|
||||
// 3. Proračun statistike na klijentskoj strani
|
||||
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
|
||||
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
|
||||
const zavrsenoCount = nalozi.filter(n => ['zavrsen', 'završeno', 'gotovo', 'zavrseno'].includes(n.status?.toLowerCase())).length;
|
||||
|
||||
// 4. 🚀 USKLAĐENO HVAĆANJE I FILTRIRANJE PREMA TVOM data-filter PROPSU:
|
||||
const card1 = sekcija.querySelector('[data-filter="planirano"]');
|
||||
if (card1) {
|
||||
const span = card1.querySelector('span');
|
||||
if (span) span.textContent = `Planirano: ${planiranoCount}`;
|
||||
if (planiranoCount > 0) card1.classList.remove('hidden'); else card1.classList.add('hidden');
|
||||
}
|
||||
|
||||
const card2 = sekcija.querySelector('[data-filter="u_radu"]');
|
||||
if (card2) {
|
||||
const span = card2.querySelector('span');
|
||||
if (span) span.textContent = `U radu: ${uRaduCount}`;
|
||||
if (uRaduCount > 0) card2.classList.remove('hidden'); else card2.classList.add('hidden');
|
||||
}
|
||||
|
||||
const card3 = sekcija.querySelector('[data-filter="zavrseno"]');
|
||||
if (card3) {
|
||||
const span = card3.querySelector('span');
|
||||
if (span) span.textContent = `Završeno: ${zavrsenoCount}`;
|
||||
if (zavrsenoCount > 0) card3.classList.remove('hidden'); else card3.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 5. Filtriranje i limitiranje niza prema klijentskom stanju URL-a
|
||||
let filtriraniNalozi = statusFilter ? nalozi.filter(n => n.status?.toLowerCase() === statusFilter) : nalozi;
|
||||
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
|
||||
|
||||
const glavniNaslov = sekcija.querySelector('h2');
|
||||
if (glavniNaslov) {
|
||||
glavniNaslov.innerText = statusFilter ? `Filtrirano: ${formatStatus(statusFilter)}` : originalNaslov;
|
||||
}
|
||||
|
||||
sekcija.setAttribute('data-state', 'loaded');
|
||||
|
||||
// 7. Generiranje HTML kartica radnih naloga unutar tijela
|
||||
if (prikazaniNalozi.length === 0) {
|
||||
container.innerHTML = `<div class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest">Nema zapisa za odabrani status</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = prikazaniNalozi.map((n) => {
|
||||
const statusMalo = n.status?.toLowerCase();
|
||||
const statusKlasa = getStatusColorClass(statusMalo);
|
||||
const ikona = statusMalo === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice';
|
||||
|
||||
const klijentPrikaz = n.kupac_naziv || n.klijent_naziv || 'Nepoznat klijent';
|
||||
const strojPrikaz = n.stroj_naziv || 'Bez stroja';
|
||||
const voziloPrikaz = n.vozilo_naziv || 'Bez vozila';
|
||||
|
||||
return `
|
||||
<a href="/operativa/radni-nalozi/${n.id}" class="flex flex-wrap items-center justify-between p-6 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700/50 last:border-none hover:bg-gray-50/50 dark:hover:bg-gray-900/20 transition-all gap-4 group">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-2xl flex items-center justify-center text-lg shadow-sm group-hover:scale-105 transition-transform">
|
||||
<i class="fa-solid ${ikona}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">#${n.broj_naloga}</h4>
|
||||
<p class="text-[11px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">${formatStatus(statusMalo)}</p>
|
||||
<p class="text-[10px] text-gray-400/70 font-medium mt-1">
|
||||
${klijentPrikaz} | ${voziloPrikaz} | ${strojPrikaz}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] font-black px-3 py-1.5 rounded-full border uppercase tracking-wider ${statusKlasa}">
|
||||
${formatStatus(statusMalo)}
|
||||
</span>
|
||||
<div class="w-9 h-9 flex items-center justify-center bg-gray-50 dark:bg-gray-700/50 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 rounded-xl border border-gray-100 dark:border-gray-600 shadow-sm transition-all">
|
||||
<i class="fa-solid fa-chevron-right text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
initFilters();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Kritična greška u klijentskom renderu radnih naloga:", err);
|
||||
sekcija.setAttribute('data-state', 'error');
|
||||
container.innerHTML = '<div class="p-10 text-center text-red-500 font-bold uppercase text-xs">Greška pri sinkronizaciji operativnih podataka.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
renderirajRadneNalogeKlijentski();
|
||||
document.addEventListener('astro:after-swap', renderirajRadneNalogeKlijentski);
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
// src/components/Navbar.astro
|
||||
import site from "../data/site.json";
|
||||
import Button from "./Button.astro";
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
@@ -9,7 +10,6 @@ const currentPath = Astro.url.pathname;
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20 items-center">
|
||||
|
||||
<!-- LOGO SEKCIJA -->
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<div class="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:rotate-6 transition-transform duration-300">
|
||||
@@ -21,87 +21,159 @@ const currentPath = Astro.url.pathname;
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="nav-auth-section" class="hidden flex items-center gap-4 group/auth" data-state="loading">
|
||||
|
||||
<div class="hidden group-data-[state=anonymous]/auth:block">
|
||||
<Button
|
||||
id="nav-login-btn"
|
||||
href="/login"
|
||||
variant="none"
|
||||
class="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-2xl text-xs font-black uppercase tracking-wider shadow-lg shadow-blue-500/20 active:scale-95 transition-all border border-blue-500 cursor-pointer italic"
|
||||
>
|
||||
<i class="fa-solid fa-right-to-bracket"></i> Prijava
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="hidden group-data-[state=authenticated]/auth:block">
|
||||
<Button
|
||||
id="mobile-menu-button"
|
||||
variant="none"
|
||||
class="w-12 h-12 flex items-center justify-center rounded-2xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 active:scale-90 transition-all border border-gray-100 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<i class="fa-solid fa-bars-staggered text-xl"></i>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- MOBILE MENU GUMB -->
|
||||
<div class="md:hidden flex items-center">
|
||||
<button
|
||||
id="mobile-menu-button"
|
||||
type="button"
|
||||
class="w-12 h-12 flex items-center justify-center rounded-2xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 active:scale-90 transition-all border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<i class="fa-solid fa-bars-staggered text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LEBDEĆI MOBILNI IZBORNIK (Transparentan) -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="hidden absolute top-[calc(100%+0.5rem)] left-4 right-4
|
||||
class="hidden absolute top-[calc(100%+0.5rem)] md:left-auto left-4 right-4 md:right-8 md:w-80
|
||||
bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl
|
||||
border border-white/20 dark:border-gray-800/50
|
||||
p-4 rounded-[2.5rem] shadow-2xl z-[100]
|
||||
p-4 rounded-[2.5rem] shadow-2xl shadow-blue-500/5 z-[100]
|
||||
transition-all animate-in fade-in slide-in-from-top-4"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
{site.navigation.map((item) => (
|
||||
<a
|
||||
href={item.url}
|
||||
class="flex items-center justify-between px-6 py-4 rounded-[1.8rem] font-black text-[11px] uppercase tracking-widest text-gray-600 dark:text-gray-300 hover:bg-blue-600 hover:text-white transition-all duration-300 group"
|
||||
{site.navigation.map((item) => {
|
||||
// Provjera je li trenutna putanja aktivna (uzima u obzir i potencijalne dinamičke ID pod-rute)
|
||||
const isCurrent = currentPath === item.url || (item.url === '/operativa/serviseri' && currentPath.includes('/operativa/serviseri'));
|
||||
|
||||
return (
|
||||
<a
|
||||
href={item.url}
|
||||
data-nav-link={item.url}
|
||||
class={`flex items-center justify-between px-6 py-4 rounded-[1.8rem] font-black text-[11px] uppercase tracking-widest transition-all duration-300 group italic ${
|
||||
isCurrent
|
||||
? "bg-blue-600 text-white shadow-lg shadow-blue-500/20"
|
||||
: "text-gray-600 dark:text-gray-300 hover:bg-blue-600 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
<i class="fa-solid fa-chevron-right text-[10px] opacity-30 group-hover:translate-x-1 group-hover:opacity-100 transition-all"></i>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
<div class="w-full mt-1 mb-2">
|
||||
<Button
|
||||
href="/operativa/radni-nalozi/novi"
|
||||
variant="none"
|
||||
class="w-full bg-blue-600 text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center shadow-lg shadow-blue-500/40 active:scale-95 transition-all block italic"
|
||||
>
|
||||
{item.name}
|
||||
<i class="fa-solid fa-chevron-right text-[10px] opacity-30 group-hover:translate-x-1 group-hover:opacity-100 transition-all"></i>
|
||||
</a>
|
||||
))}
|
||||
<i class="fa-solid fa-plus mr-2"></i> Novi nalog
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr class="my-2 border-gray-100/50 dark:border-gray-800/50" />
|
||||
<a href="/operativa/radni-nalozi/novi" class="w-full bg-blue-600 text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center shadow-lg shadow-blue-500/40 active:scale-95 transition-all">
|
||||
<i class="fa-solid fa-plus mr-2"></i> Novi nalog
|
||||
</a>
|
||||
|
||||
<div class="w-full">
|
||||
<Button
|
||||
id="logout-mobile"
|
||||
variant="none"
|
||||
class="w-full bg-red-600/10 text-red-600 hover:bg-red-600 hover:text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center active:scale-95 transition-all border border-red-100/30 dark:border-red-900/30 cursor-pointer block italic"
|
||||
>
|
||||
<i class="fa-solid fa-right-from-bracket mr-2"></i> Odjava iz sustava
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
function setupNavbar() {
|
||||
import { logout, fetchCurrentUser } from "../lib/api";
|
||||
|
||||
async function setupNavbar() {
|
||||
const btn = document.getElementById('mobile-menu-button');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
const authSection = document.getElementById('nav-auth-section');
|
||||
const logoutMobile = document.getElementById('logout-mobile');
|
||||
|
||||
if (btn && menu) {
|
||||
// Re-inicijalizacija gumba za Astro View Transitions
|
||||
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
|
||||
btn.parentNode?.replaceChild(newBtn, btn);
|
||||
if (!authSection) return;
|
||||
|
||||
newBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||
const stanjeAutentifikacije = token ? 'authenticated' : 'anonymous';
|
||||
|
||||
// Zatvori menu na klik izvan njega
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.contains(e.target as Node) && !newBtn.contains(e.target as Node)) {
|
||||
menu.classList.add('hidden');
|
||||
// 1. Postavljamo stanje u DOM (odmah prebacuje vidljivost gumbova Prijava / Menu)
|
||||
authSection.setAttribute('data-state', stanjeAutentifikacije);
|
||||
authSection.classList.remove('hidden');
|
||||
authSection.classList.add('flex');
|
||||
|
||||
// 2. Dinamičko popravljanje URL putanje za servisera ako imamo token i menu elemente
|
||||
if (token && menu) {
|
||||
let userProfil = null;
|
||||
|
||||
try {
|
||||
// 🚀 OBRANA OD KRAHA: Omotavamo hvat u zaseban try-catch kako greška s API-ja ne bi srušila cijeli navbar
|
||||
userProfil = await fetchCurrentUser();
|
||||
} catch (apiErr) {
|
||||
console.warn("Navbar nije uspio dohvatiti trenutnog korisnika s API-ja:", apiErr);
|
||||
}
|
||||
|
||||
if (userProfil && userProfil.id) {
|
||||
const serviserLink = menu.querySelector('[data-nav-link="/operativa/serviseri"]') as HTMLAnchorElement;
|
||||
if (serviserLink) {
|
||||
serviserLink.href = `/operativa/serviseri/${userProfil.id}`;
|
||||
|
||||
// Popravak aktivne klase ukoliko smo točno na toj dinamičkoj ruti
|
||||
if (window.location.pathname === `/operativa/serviseri/${userProfil.id}`) {
|
||||
serviserLink.classList.add('bg-blue-600', 'text-white', 'shadow-lg', 'shadow-blue-500/20');
|
||||
serviserLink.classList.remove('text-gray-600', 'dark:text-gray-300');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Zatvori nakon odabira linka
|
||||
menu.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => menu.classList.add('hidden'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!btn || !menu) return;
|
||||
|
||||
// Resetiranje event listenera kloniranjem (sprječava duplanje na View Transitions okidanju)
|
||||
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
|
||||
btn.parentNode?.replaceChild(newBtn, btn);
|
||||
|
||||
newBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.contains(e.target as Node) && !newBtn.contains(e.target as Node)) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
menu.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => menu.classList.add('hidden'));
|
||||
});
|
||||
|
||||
logoutMobile?.addEventListener('click', () => {
|
||||
logout();
|
||||
});
|
||||
}
|
||||
|
||||
// Logout funkcija
|
||||
const logout = () => {
|
||||
sessionStorage.removeItem('user_session');
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
document.getElementById('logout-btn')?.addEventListener('click', logout);
|
||||
document.getElementById('logout-mobile')?.addEventListener('click', logout);
|
||||
|
||||
// Okidanje na inicijalno učitavanje i Astro after-swap
|
||||
setupNavbar();
|
||||
document.addEventListener('astro:after-swap', setupNavbar);
|
||||
|
||||
</script>
|
||||
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
|
||||
import { fetchDashboardData } from "../lib/api";
|
||||
import { getStatusColorClass, formatStatus } from "../utils/ui";
|
||||
import NaslovList from "./NaslovList.astro";
|
||||
import GenericKarticaItem from "./GenericKarticaItem.astro";
|
||||
|
||||
interface Props {
|
||||
limit?: number;
|
||||
@@ -16,80 +13,190 @@ const {
|
||||
naslov = "Zadnje aktivnosti",
|
||||
prikaziNaslov = true,
|
||||
} = Astro.props;
|
||||
|
||||
// 1. Dohvat filtera iz URL-a (pretvaramo u mala slova radi sigurnosti)
|
||||
const statusFilter = Astro.url.searchParams.get('status')?.toLowerCase();
|
||||
|
||||
// 2. Dohvat podataka s API-ja
|
||||
const { nalozi = [] } = await fetchDashboardData();
|
||||
|
||||
// 3. Logika filtriranja (Serverska strana - tolerantna na velika/mala slova)
|
||||
let filtriraniNalozi = nalozi;
|
||||
|
||||
if (statusFilter) {
|
||||
filtriraniNalozi = nalozi.filter(n => n.status?.toLowerCase() === statusFilter);
|
||||
}
|
||||
|
||||
// 4. POPRAVLJENO: Izračun brojeva uz obvezno pretvaranje u mala slova (.toLowerCase())
|
||||
// Na ovaj način 'PLANIRANO' s backenda sigurno postaje 'planirano' u JS-u
|
||||
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
|
||||
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
|
||||
|
||||
// 5. Primjena limita na prikaz (npr. top 5 nalozi)
|
||||
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
|
||||
|
||||
// Dinamički naslov ovisno o filteru
|
||||
const prikazaniNaslov = statusFilter
|
||||
? `Filtrirano: ${formatStatus(statusFilter)}`
|
||||
: naslov;
|
||||
---
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-6 group" id="radni-nalozi-sekcija" data-state="loading">
|
||||
{prikaziNaslov && (
|
||||
<NaslovList
|
||||
naslov={prikazaniNaslov}
|
||||
ukupno={filtriraniNalozi.length}
|
||||
label1="Planirano"
|
||||
count1={planiranoCount} // Sada će ispravno prikazati stvarni broj
|
||||
filter1="planirano"
|
||||
label2="U radu"
|
||||
count2={uRaduCount} // Sada će ispravno prikazati stvarni broj
|
||||
filter2="u_radu"
|
||||
naslov={naslov}
|
||||
sinkronizirano={false} label1="Planirano" count1={0} filter1="planirano"
|
||||
label2="U radu" count2={0} filter2="u_radu"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
|
||||
<div id="nalozi-list">
|
||||
{prikazaniNalozi.length > 0 ? (
|
||||
prikazaniNalozi.map((n) => (
|
||||
<GenericKarticaItem
|
||||
href={`/operativa/radni-nalozi/${n.id}`}
|
||||
status={n.status?.toLowerCase()} // Šaljemo ujednačeni lowercase u podkomponente
|
||||
statusBojaClass={getStatusColorClass(n.status?.toLowerCase())}
|
||||
ikona={n.status?.toLowerCase() === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice'}
|
||||
naslov={`#${n.broj_naloga}`}
|
||||
subNaslov={formatStatus(n.status?.toLowerCase())}
|
||||
metaTekst={`${n.klijent_naziv || 'Nepoznat klijent'} | ${n.vozilo_naziv || 'Bez vozila'} | ${n.stroj_naziv || 'Bez stroja'}`}
|
||||
statusPrikaz={formatStatus(n.status?.toLowerCase())}
|
||||
bojaTeme={n.boja_teme || 'blue'}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div class="p-20 text-center uppercase font-black opacity-20 italic">
|
||||
Nema zapisa za odabrani status
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
id="nalozi-loader"
|
||||
class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse group-data-[state=loaded]:hidden group-data-[state=error]:hidden"
|
||||
>
|
||||
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija radnih naloga...
|
||||
</div>
|
||||
|
||||
<div id="nalozi-list" class="space-y-0">
|
||||
<div id="nalozi-placeholder" class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest group-data-[state=loaded]:hidden">
|
||||
Nema zapisa za odabrani status
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Ponovno inicijaliziramo klijentsku skriptu za klikove na kartice filtera
|
||||
import { initFilters } from "../scripts/filters.js";
|
||||
|
||||
// Prvo pokretanje
|
||||
initFilters();
|
||||
<span id="rn-bridge" data-limit={limit} data-naslov={naslov}></span>
|
||||
|
||||
// Podrška za Astro View Transitions
|
||||
document.addEventListener('astro:after-swap', initFilters);
|
||||
<script>
|
||||
import { fetchDashboardData } from "../lib/api";
|
||||
import { getStatusColorClass, formatStatus } from "../utils/ui";
|
||||
import { initFilters } from "../scripts/filters.js";
|
||||
|
||||
async function renderirajRadneNalogeKlijentski() {
|
||||
const container = document.getElementById('nalozi-list');
|
||||
const sekcija = document.getElementById('radni-nalozi-sekcija');
|
||||
const bridge = document.getElementById('rn-bridge');
|
||||
|
||||
if (!container || !sekcija || !bridge) return;
|
||||
|
||||
// 1. KLIJENTSKI OSIGURAČ: Provjera autorizacije prije API poziva preko ključa iz tvojeg api.js
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||
|
||||
if (!token) {
|
||||
// Ako nema tokena, odmah zaključaj listu i baci sigurnosni Error izgled
|
||||
sekcija.setAttribute('data-state', 'error');
|
||||
container.innerHTML = `
|
||||
<div class="p-20 text-center flex flex-col items-center justify-center gap-3">
|
||||
<div class="w-12 h-12 bg-red-600/10 text-red-600 rounded-xl flex items-center justify-center text-lg mb-2">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
</div>
|
||||
<h4 class="text-xs font-black uppercase tracking-widest text-red-600">Pristup odbijen</h4>
|
||||
<p class="text-[10px] text-gray-400 font-bold uppercase tracking-wider italic">Niste autorizirani za pregled operativnih naloga flote.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = parseInt(bridge.getAttribute('data-limit') || '0');
|
||||
const originalNaslov = bridge.getAttribute('data-naslov') || 'Zadnje aktivnosti';
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const statusFilter = urlParams.get('status')?.toLowerCase();
|
||||
|
||||
try {
|
||||
// Svaki put kad klijent pokrene funkciju, osiguravamo loading stanje
|
||||
sekcija.setAttribute('data-state', 'loading');
|
||||
|
||||
// 2. Dohvat podataka (Šalje fetch zahtjev s ispravnim Authorization zaglavljem)
|
||||
const data = await fetchDashboardData();
|
||||
const nalozi = data?.nalozi || [];
|
||||
|
||||
// 3. Proračun statistike na klijentskoj strani
|
||||
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
|
||||
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
|
||||
const zavrsenoCount = nalozi.filter(n => ['zavrsen', 'završeno', 'gotovo', 'zavrseno'].includes(n.status?.toLowerCase())).length;
|
||||
|
||||
// 4. 🛠️ POPRAVLJENO: Upisivanje vrijednosti i inteligentno sakrivanje samo ako je brojač točno 0
|
||||
const card1 = sekcija.querySelector('#card-filter1');
|
||||
if (card1) {
|
||||
const val = card1.querySelector('.count-value');
|
||||
if (val) val.textContent = planiranoCount.toString();
|
||||
|
||||
if (planiranoCount > 0) {
|
||||
card1.classList.remove('hidden');
|
||||
} else {
|
||||
card1.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
const card2 = sekcija.querySelector('#card-filter2');
|
||||
if (card2) {
|
||||
const val = card2.querySelector('.count-value');
|
||||
if (val) val.textContent = uRaduCount.toString();
|
||||
|
||||
if (uRaduCount > 0) {
|
||||
card2.classList.remove('hidden');
|
||||
} else {
|
||||
card2.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
const card3 = sekcija.querySelector('#card-filter3');
|
||||
if (card3) {
|
||||
const val = card3.querySelector('.count-value');
|
||||
if (val) val.textContent = zavrsenoCount.toString();
|
||||
|
||||
if (zavrsenoCount > 0) {
|
||||
card3.classList.remove('hidden');
|
||||
} else {
|
||||
card3.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Filtriranje i limitiranje niza prema klijentskom stanju URL-a
|
||||
let filtriraniNalozi = statusFilter ? nalozi.filter(n => n.status?.toLowerCase() === statusFilter) : nalozi;
|
||||
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
|
||||
|
||||
// Ažuriranje teksta glavnog naslova ovisno o aktivnom filteru
|
||||
const glavniNaslov = sekcija.querySelector('h2');
|
||||
if (glavniNaslov) {
|
||||
glavniNaslov.innerText = statusFilter ? `Filtrirano: ${formatStatus(statusFilter)}` : originalNaslov;
|
||||
}
|
||||
|
||||
// 6. 🚀 SINKRONIZACIJA PROPSA: Otključavamo kontejner unutar NaslovList.astro prebacivanjem u 'loaded'
|
||||
const statCardsContainer = sekcija.querySelector('#stat-cards-container');
|
||||
if (statCardsContainer) {
|
||||
statCardsContainer.setAttribute('data-state', 'loaded');
|
||||
}
|
||||
|
||||
// Isključivanje općeg loading stanja s krovne sekcije
|
||||
sekcija.setAttribute('data-state', 'loaded');
|
||||
|
||||
// 7. Generiranje HTML kartica radnih naloga unutar tijela
|
||||
if (prikazaniNalozi.length === 0) {
|
||||
container.innerHTML = `<div class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest">Nema zapisa za odabrani status</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = prikazaniNalozi.map((n) => {
|
||||
const statusMalo = n.status?.toLowerCase();
|
||||
const statusKlasa = getStatusColorClass(statusMalo);
|
||||
const ikona = statusMalo === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice';
|
||||
|
||||
return `
|
||||
<a href="/operativa/radni-nalozi/${n.id}" class="flex flex-wrap items-center justify-between p-6 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700/50 last:border-none hover:bg-gray-50/50 dark:hover:bg-gray-900/20 transition-all gap-4 group">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-2xl flex items-center justify-center text-lg shadow-sm group-hover:scale-105 transition-transform">
|
||||
<i class="fa-solid ${ikona}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">#${n.broj_naloga}</h4>
|
||||
<p class="text-[11px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">${formatStatus(statusMalo)}</p>
|
||||
<p class="text-[10px] text-gray-400/70 font-medium mt-1">
|
||||
${n.klijent_naziv || 'Nepoznat klijent'} | ${n.vozilo_naziv || 'Bez vozila'} | ${n.stroj_naziv || 'Bez stroja'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] font-black px-3 py-1.5 rounded-full border uppercase tracking-wider ${statusKlasa}">
|
||||
${formatStatus(statusMalo)}
|
||||
</span>
|
||||
<div class="w-9 h-9 flex items-center justify-center bg-gray-50 dark:bg-gray-700/50 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 rounded-xl border border-gray-100 dark:border-gray-600 shadow-sm transition-all">
|
||||
<i class="fa-solid fa-chevron-right text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Aktiviraj klijentske klikove na kartice filtera
|
||||
initFilters();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Kritična greška u klijentskom renderu radnih naloga:", err);
|
||||
sekcija.setAttribute('data-state', 'error');
|
||||
container.innerHTML = '<div class="p-10 text-center text-red-500 font-bold uppercase text-xs">Greška pri sinkronizaciji operativnih podataka.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Prvo pokretanje pri učitavanju stranice
|
||||
renderirajRadneNalogeKlijentski();
|
||||
|
||||
// Ponovno pokretanje prilikom SPA navigacije kroz aplikaciju
|
||||
document.addEventListener('astro:after-swap', renderirajRadneNalogeKlijentski);
|
||||
</script>
|
||||
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",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Pregled",
|
||||
"welcomeHeaderTextH1dodatno": "klijenata",
|
||||
"welcomeHeaderTextH1dodatno": " klijenata",
|
||||
"welcomeHeaderPodnaslov": "Upravljanje bazom korisnika i partnera",
|
||||
"welcomeHeaderPovratniURL": "/"
|
||||
},
|
||||
@@ -64,7 +64,7 @@
|
||||
"icon": "fa-calendar-days",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Kalendar",
|
||||
"welcomeHeaderTextH1dodatno": "događaja",
|
||||
"welcomeHeaderTextH1dodatno": " događaja",
|
||||
"welcomeHeaderPodnaslov": "Pregled operacija u stvarnom vremenu",
|
||||
"welcomeHeaderPovratniURL": "/"
|
||||
},
|
||||
@@ -87,6 +87,16 @@
|
||||
"welcomeHeaderTextH1dodatno": " Radni nalog",
|
||||
"welcomeHeaderPodnaslov": "Otvaranje novog servisnog ili radnog naloga u sustavu",
|
||||
"welcomeHeaderPovratniURL": "/operativa/radni-nalozi"
|
||||
},
|
||||
{
|
||||
"name": "Evidencija Servisera",
|
||||
"url": "/operativa/serviseri",
|
||||
"icon": "fa-calendar-days",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Evidencija",
|
||||
"welcomeHeaderTextH1dodatno": " servisera",
|
||||
"welcomeHeaderPodnaslov": "Središnji pregled i administracija terenskih servisnih tehničara",
|
||||
"welcomeHeaderPovratniURL": "/"
|
||||
}
|
||||
],
|
||||
"api_endpoints": {
|
||||
|
||||
@@ -4,27 +4,38 @@
|
||||
const RAW_BASE = import.meta.env.PUBLIC_API_URL;
|
||||
const API_BASE = RAW_BASE.endsWith('/') ? RAW_BASE : `${RAW_BASE}/`;
|
||||
|
||||
|
||||
/**
|
||||
* POMOĆNA FUNKCIJA:端 Dohvaća token i postavlja Headere
|
||||
* POMOĆNA FUNKCIJA: Dohvaća token i postavlja Headere.
|
||||
* Podržava i klijentski localStorage i serverski (SSR) proslijeđeni token.
|
||||
* @param {Object|FormData} bodyData - Podaci koji se šalju u body-ju zahtjeva
|
||||
* @param {string|null} ssrToken - Izborni token proslijeđen sa serverske strane (Astro SSR)
|
||||
*/
|
||||
function getAuthHeaders(bodyData = {}) {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||
function getAuthHeaders(bodyData = {}, ssrToken = null) {
|
||||
const headers = {};
|
||||
|
||||
// 🚀 PAMETAN DOHVAT TOKENA: Ako je proslijeđen ssrToken (server), koristi njega.
|
||||
// Ako nije, a nalazimo se u pregledniku (klijent), povuci ga iz localStorage.
|
||||
let token = ssrToken;
|
||||
if (!token && typeof window !== 'undefined') {
|
||||
token = localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`; // Usklađeno sa Simple JWT standardom
|
||||
}
|
||||
|
||||
// Provjera je li bodyData FormData (za slike/naloge)
|
||||
// Provjera je li bodyData FormData (za slike/naloge s terena)
|
||||
const isFormData = bodyData instanceof FormData;
|
||||
|
||||
// Ako NIJE FormData, šaljemo JSON
|
||||
// Ako NIJE FormData, šaljemo standardni JSON content type
|
||||
if (!isFormData) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizirana obrada odgovora s Toast podrškom
|
||||
*/
|
||||
@@ -69,9 +80,13 @@ async function handleResponse(res) {
|
||||
|
||||
// --- RUTE ---
|
||||
export const routes = {
|
||||
// Autentifikacija (Maknute početne kose crte!)
|
||||
// Autentifikacija
|
||||
login: () => 'token/',
|
||||
trenutniKorisnik: () => 'users/me/',
|
||||
|
||||
// Korisnici i operativni tehničari
|
||||
serviseriSvi: () => 'users/',
|
||||
serviserTerminal: (id) => `users/${id}/terminal/`,
|
||||
|
||||
// Radni nalozi
|
||||
radniNalozi: (params = {}) => {
|
||||
@@ -109,22 +124,72 @@ export const routes = {
|
||||
// --- API METODE ---
|
||||
|
||||
/**
|
||||
* Dohvat profila trenutno prijavljenog korisnika
|
||||
* Dohvat profila korisnika iz opće liste /api/users/
|
||||
* @param {string|null} ssrToken - Opcionalni token sa servera (Astro.cookies ili slično)
|
||||
*/
|
||||
export async function fetchCurrentUser() {
|
||||
export async function fetchCurrentUser(ssrToken = null) {
|
||||
if (typeof window === 'undefined' && !ssrToken) return null;
|
||||
|
||||
try {
|
||||
const url = `${API_BASE}${routes.trenutniKorisnik()}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders()
|
||||
headers: getAuthHeaders({}, ssrToken)
|
||||
});
|
||||
return await handleResponse(res);
|
||||
|
||||
const user = await handleResponse(res);
|
||||
if (user && user.detail) return null;
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
console.error("fetchCurrentUser Failure:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 NOVO (DRY): Dohvaća sve korisnike i vraća isključivo filtrirane servisere s terena
|
||||
*/
|
||||
export async function fetchServiseri() {
|
||||
try {
|
||||
const url = `${API_BASE}${routes.serviseriSvi()}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
const korisnici = await handleResponse(res);
|
||||
if (!korisnici) return [];
|
||||
|
||||
const lista = Array.isArray(korisnici) ? korisnici : (korisnici.results || []);
|
||||
|
||||
// Filtriranje na temelju polja 'uloga' iz tvog CustomUser modela
|
||||
return lista.filter(u => String(u.uloga).toUpperCase().trim() === 'SERVISER');
|
||||
} catch (e) {
|
||||
console.error("fetchServiseri Failure:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 NOVO (DRY): Dohvaća ugniježđene terminal podatke za specifičnog servisera (Nalozi, Putni, Vozila)
|
||||
* @param {number|string} id - ID servisera čiji se terminal učitava
|
||||
*/
|
||||
export async function fetchServiserTerminalData(id) {
|
||||
try {
|
||||
const url = `${API_BASE}${routes.serviserTerminal(id)}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
return await handleResponse(res);
|
||||
} catch (e) {
|
||||
console.error(`fetchServiserTerminalData za ID ${id} neuspješan:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dohvat i filtriranje radnih naloga (za tablice i liste u operativi)
|
||||
*/
|
||||
|
||||
@@ -8,19 +8,14 @@ import StatsGrid from "../components/StatsGrid.astro";
|
||||
import Kalendar from "../components/Kalendar/Kalendar.astro";
|
||||
|
||||
// API i Centralizirani Utils iz api.js
|
||||
import { fetchDashboardData, fetchCurrentUser } from "../lib/api";
|
||||
import { fetchDashboardData } from "../lib/api";
|
||||
|
||||
// 1. Dohvat podataka (paralelno radi brzine i performansi u homelabu)
|
||||
const [data, user] = await Promise.all([
|
||||
fetchDashboardData(),
|
||||
fetchCurrentUser()
|
||||
]);
|
||||
// 1. Dohvat podataka s backenda (Samo bazični dashboard podaci koji se mogu poslužiti asinkrono)
|
||||
const data = await fetchDashboardData();
|
||||
|
||||
const nalozi = data?.nalozi || [];
|
||||
const imeKorisnika = user?.first_name || "Kolega";
|
||||
|
||||
// 2. POPRAVLJENO: Kalkulacija statistike tolerantna na velika/mala slova (.toLowerCase())
|
||||
// Osiguravamo da se podaci iz baze (PLANIRANO, U_RADU) savršeno upare sa StatsGrid filterima
|
||||
// 2. Kalkulacija statistike tolerantna na velika/mala slova (.toLowerCase())
|
||||
const naloziURadu = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
|
||||
const planiraniNalozi = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
|
||||
|
||||
@@ -33,7 +28,7 @@ const zavrseniNalozi = nalozi.filter(n => {
|
||||
<Layout title="Dashboard | ServisLog">
|
||||
<div class="w-full space-y-5">
|
||||
|
||||
<WelcomeHeader ime={imeKorisnika} />
|
||||
<WelcomeHeader naslov="Kontrolna ploča" />
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 sm:gap-6 px-2">
|
||||
<StatsGrid
|
||||
@@ -84,10 +79,19 @@ const zavrseniNalozi = nalozi.filter(n => {
|
||||
|
||||
<script>
|
||||
import { initFilters } from "../scripts/filters.js";
|
||||
import { provjeriPristupStranici } from "../utils/auth.js";
|
||||
|
||||
// Inicijalizacija filtera za StatsGrid klikove na klijentu
|
||||
initFilters();
|
||||
// 🚀 POKREĆEMO SIGURNOSNI KLIJENTSKI GUARD:
|
||||
// On odmah na klijentu provjerava is_serviser. Ako je ulogiran običan serviser,
|
||||
// prisilno ga i trenutno preusmjerava na njegov dinamički terminal /operativa/serviseri/[id]
|
||||
async function inicijalizirajStranicu() {
|
||||
await provjeriPristupStranici();
|
||||
initFilters();
|
||||
}
|
||||
|
||||
// Re-inicijalizacija kod navigacije (Astro View Transitions)
|
||||
document.addEventListener('astro:after-swap', initFilters);
|
||||
// Pokreni odmah pri učitavanju u preglednik
|
||||
inicijalizirajStranicu();
|
||||
|
||||
// Re-inicijalizacija kod klijentske navigacije (Astro View Transitions)
|
||||
document.addEventListener('astro:after-swap', inicijalizirajStranicu);
|
||||
</script>
|
||||
@@ -3,23 +3,31 @@
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import WelcomeHeader from "../components/WelcomeHeader.astro";
|
||||
import Kalendar from "../components/Kalendar/Kalendar.astro";
|
||||
|
||||
// API i Utils
|
||||
import { fetchCurrentUser } from "../lib/api";
|
||||
|
||||
// 1. Dohvaćamo samo korisnika za pozdrav u headeru
|
||||
const user = await fetchCurrentUser();
|
||||
const imeKorisnika = user?.first_name || "Kolega";
|
||||
---
|
||||
|
||||
<Layout title="Kalendar | ServisLog">
|
||||
<div class="w-full space-y-10">
|
||||
|
||||
<WelcomeHeader ime={imeKorisnika} />
|
||||
<WelcomeHeader naslov="Kalendar događaja" />
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:gap-6 px-2">
|
||||
<Kalendar />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { provjeriPristupStranici } from "../utils/auth.js";
|
||||
|
||||
async function inicijalizirajStranicu() {
|
||||
// 🚀 SIGURNOSNI UKLOP: Guard provjerava sesiju na klijentu i štiti kalendar od neautoriziranih upada
|
||||
await provjeriPristupStranici();
|
||||
}
|
||||
|
||||
// Pokreni odmah pri učitavanju u preglednik
|
||||
inicijalizirajStranicu();
|
||||
|
||||
// Re-inicijalizacija kod klijentske navigacije kroz ERP (Astro View Transitions)
|
||||
document.addEventListener('astro:after-swap', inicijalizirajStranicu);
|
||||
</script>
|
||||
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