add from v002
This commit is contained in:
26
.gitignore
vendored
26
.gitignore
vendored
@@ -174,3 +174,29 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
6
001.BACKEND/.vscode/settings.json
vendored
Normal file
6
001.BACKEND/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/env/Scripts/python.exe",
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}"
|
||||
]
|
||||
}
|
||||
0
001.BACKEND/core/__init__.py
Normal file
0
001.BACKEND/core/__init__.py
Normal file
16
001.BACKEND/core/asgi.py
Normal file
16
001.BACKEND/core/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for core project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
209
001.BACKEND/core/settings.py
Normal file
209
001.BACKEND/core/settings.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Django settings for core project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
load_dotenv(BASE_DIR / '.env')
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv(
|
||||
'DJANGO_SECRET_KEY',
|
||||
'django-insecure-!228(8gy#3a7l-@_^g1s4bipj&@*+_415+ulx0^-9jw(%ksdvy',
|
||||
)
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', 'True').lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
# Custom user model
|
||||
AUTH_USER_MODEL = 'users.CustomUser'
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
host.strip()
|
||||
for host in os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost').split(',')
|
||||
if host.strip()
|
||||
]
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
# plugins
|
||||
'fleet',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'rest_framework_simplejwt',
|
||||
'rest_framework',
|
||||
# Aplikacije
|
||||
'users',
|
||||
'kupci',
|
||||
'operations',
|
||||
'kalendar',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware', # Mora biti prvi!
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'core.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'Europe/Zagreb'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
|
||||
# Automatski koristi filtriranje na svim API endpointima koji podržavaju filtriranje
|
||||
|
||||
if DEBUG:
|
||||
# Razvojni način: Svatko može čitati i pisati bez tokena
|
||||
DEFAULT_PERMISSION_CLASSES = [
|
||||
'rest_framework.permissions.AllowAny',
|
||||
]
|
||||
else:
|
||||
# Produkcijski način: Pristup samo uz ispravan JWT token
|
||||
DEFAULT_PERMISSION_CLASSES = [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend'
|
||||
],
|
||||
# JWT Autentikacija
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
# Postavi da su po defaultu svi API-ji zaključani (samo za prijavljene)
|
||||
'DEFAULT_PERMISSION_CLASSES': DEFAULT_PERMISSION_CLASSES
|
||||
}
|
||||
|
||||
|
||||
# Podesiti koliko dugo vrijede JWT tokeni (npr. 1 sat za pristup, 30 dana za refresh)
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
}
|
||||
|
||||
# Media files (npr. slike vozila)
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
|
||||
|
||||
# CORS postavke - dozvoli frontend aplikaciji da komunicira s backendom
|
||||
|
||||
if DEBUG:
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
else:
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:4321", # Ovo je adresa na kojoj će Astro frontend biti dostupan
|
||||
"http://127.0.0.1:4321",
|
||||
]
|
||||
CORS_ALLOW_METHODS = [
|
||||
"DELETE",
|
||||
"GET",
|
||||
"OPTIONS",
|
||||
"PATCH",
|
||||
"POST",
|
||||
"PUT",
|
||||
]
|
||||
|
||||
CORS_ALLOW_HEADERS = [
|
||||
"accept",
|
||||
"authorization",
|
||||
"content-type",
|
||||
"user-agent",
|
||||
"x-csrftoken",
|
||||
"x-requested-with",
|
||||
]
|
||||
43
001.BACKEND/core/urls.py
Normal file
43
001.BACKEND/core/urls.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
URL configuration for core project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt.views import (
|
||||
TokenObtainPairView,
|
||||
TokenRefreshView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Admin panel
|
||||
path('admin/', admin.site.urls),
|
||||
# API endpointi
|
||||
path('api/kupci/', include('kupci.urls')),
|
||||
path('api/fleet/', include('fleet.urls')),
|
||||
path('api/operativa/', include('operations.urls')),
|
||||
path('api/kalendar/', include('kalendar.urls')),
|
||||
path('api/users/', include('users.urls')),
|
||||
# Endpoint za dobivanje tokena (Login)
|
||||
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
# Endpoint za osvježavanje tokena (da se ne mora stalno logirati)
|
||||
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
]
|
||||
|
||||
# Dodajemo pristup media datotekama samo u development fazi
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
16
001.BACKEND/core/wsgi.py
Normal file
16
001.BACKEND/core/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for core project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
001.BACKEND/fleet/__init__.py
Normal file
0
001.BACKEND/fleet/__init__.py
Normal file
90
001.BACKEND/fleet/admin.py
Normal file
90
001.BACKEND/fleet/admin.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from .models import Stroj, Vozilo
|
||||
from operations.models import RadniNalog, PutniNalog # Importamo modele iz druge aplikacije
|
||||
|
||||
# --- INLINE KLASE ---
|
||||
|
||||
class PutniNalogInline(admin.TabularInline):
|
||||
"""Prikazuje putne naloge unutar pregleda vozila."""
|
||||
model = PutniNalog
|
||||
extra = 0
|
||||
fields = ('broj_naloga', 'datum_izdavanja', 'korisnik', 'pocetna_km', 'zavrsna_km', 'status')
|
||||
readonly_fields = ('broj_naloga', 'datum_izdavanja', 'korisnik', 'pocetna_km', 'zavrsna_km', 'status')
|
||||
can_delete = False
|
||||
verbose_name = "Povijest putovanja"
|
||||
verbose_name_plural = "Povijest putovanja za ovo vozilo"
|
||||
|
||||
class RadniNalogInline(admin.TabularInline):
|
||||
model = RadniNalog
|
||||
extra = 0
|
||||
# Umjesto običnog polja 'broj_naloga', koristimo metodu 'link_na_nalog'
|
||||
fields = ('link_na_nalog', 'datum_kreiranja', 'klijent', 'izvrsitelj', 'status')
|
||||
readonly_fields = ('link_na_nalog', 'datum_kreiranja', 'klijent', 'izvrsitelj', 'status')
|
||||
can_delete = False
|
||||
|
||||
def link_na_nalog(self, obj):
|
||||
if obj.id:
|
||||
# Generiramo URL za admin promjenu Radnog Naloga
|
||||
url = reverse('admin:operations_radninalog_change', args=[obj.id])
|
||||
return mark_safe(f'<a href="{url}" style="font-weight:bold;">RN-{obj.broj_naloga}</a>')
|
||||
return "-"
|
||||
|
||||
link_na_nalog.short_description = 'Broj naloga'
|
||||
|
||||
# --- GLAVNE ADMIN KLASE ---
|
||||
|
||||
@admin.register(Vozilo)
|
||||
class VoziloAdmin(admin.ModelAdmin):
|
||||
list_display = ('naziv', 'registracija', 'status', 'pocetni_kilometri', 'trenutni_kilometri')
|
||||
search_fields = ('naziv', 'registracija')
|
||||
list_filter = ('status',)
|
||||
ordering = ('naziv',)
|
||||
|
||||
# Dodajemo inline za putne naloge
|
||||
inlines = [PutniNalogInline]
|
||||
|
||||
@admin.register(Stroj)
|
||||
class StrojAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'serijski_broj',
|
||||
'prikaz_naziva',
|
||||
'vlasnik',
|
||||
'tip',
|
||||
'radni_sati',
|
||||
'registracija',
|
||||
'datum_zadnjeg_atesta'
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
'serijski_broj',
|
||||
'naziv',
|
||||
'marka',
|
||||
'model_stroja',
|
||||
'vlasnik__naziv',
|
||||
'vlasnik__oib'
|
||||
)
|
||||
|
||||
list_filter = ('tip', 'marka', 'vlasnik')
|
||||
autocomplete_fields = ['vlasnik']
|
||||
|
||||
# Dodajemo inline za radne naloge
|
||||
inlines = [RadniNalogInline]
|
||||
|
||||
fieldsets = (
|
||||
('Osnovne informacije', {
|
||||
'fields': ('vlasnik', 'naziv', 'tip', 'serijski_broj')
|
||||
}),
|
||||
('Detalji o modelu', {
|
||||
'fields': (('marka', 'model_stroja'), 'godina_proizvodnje')
|
||||
}),
|
||||
('Tehnički podaci i održavanje', {
|
||||
'fields': ('radni_sati', 'registracija', 'datum_zadnjeg_atesta'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def prikaz_naziva(self, obj):
|
||||
return f"{obj.marka} {obj.model_stroja}".strip() or obj.naziv
|
||||
prikaz_naziva.short_description = 'Marka i Model'
|
||||
5
001.BACKEND/fleet/apps.py
Normal file
5
001.BACKEND/fleet/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FleetConfig(AppConfig):
|
||||
name = 'fleet'
|
||||
86
001.BACKEND/fleet/management/commands/populate_stroj.py
Normal file
86
001.BACKEND/fleet/management/commands/populate_stroj.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from django.core.management.base import BaseCommand
|
||||
from faker import Faker
|
||||
from fleet.models import Stroj
|
||||
from kupci.models import Kupac
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Popunjava bazu podataka isključivo Liebherr strojevima koristeći Faker'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'total',
|
||||
type=int,
|
||||
help='Broj Liebherr strojeva koje želiš kreirati',
|
||||
default=10
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
total = kwargs['total']
|
||||
fake = Faker(['hr_HR']) # Koristimo hrvatski lokalitet
|
||||
kupci = list(Kupac.objects.all())
|
||||
|
||||
if not kupci:
|
||||
self.stdout.write(self.style.ERROR("Greška: Nema kupaca u bazi. Prvo pokreni populate_kupci!"))
|
||||
return
|
||||
|
||||
# Katalog usklađen s tvojim TIP_STROJA choices
|
||||
liebherr_katalog = {
|
||||
'dizalica_toranj': [
|
||||
'132 EC-H 8 Litronic',
|
||||
'280 EC-H 12',
|
||||
'81 K.1',
|
||||
'172 EC-B 8'
|
||||
],
|
||||
'dizalica_auto': [
|
||||
'LTM 1030-2.1',
|
||||
'LTM 1050-3.1',
|
||||
'LTM 1060-3.1',
|
||||
'LTM 1120-4.1',
|
||||
'LTC 1050-3.1'
|
||||
]
|
||||
}
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING(f'🚀 Generiram {total} Liebherr strojeva...'))
|
||||
|
||||
created_count = 0
|
||||
for _ in range(total):
|
||||
# Odabir tipa (usklađeno s tvojim modelom)
|
||||
tip = random.choice(['dizalica_toranj', 'dizalica_auto'])
|
||||
model_naziv = random.choice(liebherr_katalog[tip])
|
||||
|
||||
# Faker za generiranje serijskog broja i registracije
|
||||
serijski = f"LE-{fake.bothify(text='######')}" # npr. LE-482931
|
||||
|
||||
# Registracija samo za autodizalice (Faker format)
|
||||
reg = None
|
||||
if tip == 'dizalica_auto':
|
||||
# Generira format sličan ZG-1234-LH
|
||||
reg = f"ZG-{fake.numerify('####')}-LH"
|
||||
|
||||
# Radni sati
|
||||
sati = random.uniform(500.0, 12000.0) if tip == 'dizalica_toranj' else random.uniform(100.0, 5000.0)
|
||||
|
||||
# Faker za datume (unutar zadnjih godinu dana)
|
||||
datum_atesta = fake.date_between(start_date='-1y', end_date='today')
|
||||
|
||||
try:
|
||||
Stroj.objects.create(
|
||||
vlasnik=random.choice(kupci),
|
||||
naziv=f"Liebherr {model_naziv}",
|
||||
serijski_broj=serijski,
|
||||
marka='Liebherr',
|
||||
model_stroja=model_naziv,
|
||||
godina_proizvodnje=random.randint(2015, 2024),
|
||||
tip=tip,
|
||||
radni_sati=round(sati, 2),
|
||||
registracija=reg,
|
||||
datum_zadnjeg_atesta=datum_atesta
|
||||
)
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
self.stderr.write(f"Greška kod S/N {serijski}: {e}")
|
||||
continue
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'✅ Uspješno dodano {created_count} Liebherr strojeva!'))
|
||||
71
001.BACKEND/fleet/management/commands/populate_vozila.py
Normal file
71
001.BACKEND/fleet/management/commands/populate_vozila.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import random
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.exceptions import ValidationError
|
||||
from faker import Faker
|
||||
from fleet.models import Vozilo
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Popunjava bazu podataka s gospodarskim vozilima koristeći Faker'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'total',
|
||||
type=int,
|
||||
help='Broj vozila koje želiš kreirati',
|
||||
default=5
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
total = kwargs['total']
|
||||
fake = Faker(['hr_HR'])
|
||||
|
||||
# Modeli koji se pojavljuju u tvom radnom nalogu (CSV)
|
||||
modeli_podaci = [
|
||||
'VW Transporter T6',
|
||||
'Mercedes-Benz Vito',
|
||||
'Citroen Jumper',
|
||||
'Ford Transit Custom',
|
||||
'Renault Kangoo Maxi',
|
||||
'Peugeot Boxer',
|
||||
'Opel Vivaro',
|
||||
]
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING(f'🚀 Kreiram {total} vozila u flotu...'))
|
||||
|
||||
brojac = 0
|
||||
while brojac < total:
|
||||
model_naziv = random.choice(modeli_podaci)
|
||||
|
||||
# Generiranje registracije koristeći Faker numerify/bothify
|
||||
# Format: ZG 1234 AB
|
||||
gradovi = ['ZG', 'ST', 'RI', 'OS', 'VZ', 'PU', 'KA', 'KC']
|
||||
grad = random.choice(gradovi)
|
||||
reg_oznaka = fake.numerify(text='####') # npr. 6732
|
||||
reg_slova = fake.bothify(text='??', letters='ABCDEFGHIJKLMNOPQRSTUVWXYZ') # npr. II
|
||||
registracija = f"{grad}{reg_oznaka}{reg_slova}"
|
||||
|
||||
# Kilometri: Koristimo tvoju logiku da trenutni budu veći od početnih
|
||||
pocetni = random.randint(0, 150000)
|
||||
dodatni = random.randint(500, 20000)
|
||||
trenutni = pocetni + dodatni
|
||||
|
||||
try:
|
||||
# Koristimo registraciju u nazivu radi lakšeg prepoznavanja u Astro frontendu
|
||||
vozilo = Vozilo.objects.create(
|
||||
naziv=f"{model_naziv} ({grad})",
|
||||
registracija=registracija,
|
||||
pocetni_kilometri=pocetni,
|
||||
trenutni_kilometri=trenutni,
|
||||
status=random.choice(['aktivan', 'aktivan', 'servis', 'neaktivan'])
|
||||
)
|
||||
brojac += 1
|
||||
self.stdout.write(f" ✅ Kreirano: {vozilo.naziv} [{vozilo.registracija}]")
|
||||
|
||||
except ValidationError:
|
||||
# Ako Faker pogodi postojeću registraciju, samo nastavi dalje
|
||||
continue
|
||||
except Exception as e:
|
||||
self.stderr.write(f" ❌ Greška: {e}")
|
||||
break
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\nGotovo! Uspješno dodano {brojac} vozila u flotu.'))
|
||||
88
001.BACKEND/fleet/models.py
Normal file
88
001.BACKEND/fleet/models.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class Vozilo(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('aktivan', 'Aktivan'),
|
||||
('servis', 'Na Servisu'),
|
||||
('neaktivan', 'Izvan Pogona'),
|
||||
]
|
||||
|
||||
naziv = models.CharField(max_length=50, verbose_name="Interni naziv")
|
||||
registracija = models.CharField(max_length=15, unique=True, db_index=True)
|
||||
|
||||
# Kilometri
|
||||
pocetni_kilometri = models.PositiveIntegerField(default=0)
|
||||
trenutni_kilometri = models.PositiveIntegerField(default=0)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='aktivan'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Vozilo"
|
||||
verbose_name_plural = "Vozila"
|
||||
ordering = ['naziv']
|
||||
|
||||
def clean(self):
|
||||
# Osnovna logika: trenutni kilometri ne smiju biti manji od početnih
|
||||
if self.trenutni_kilometri < self.pocetni_kilometri:
|
||||
raise ValidationError({
|
||||
'trenutni_kilometri': "Trenutni kilometri ne mogu biti manji od početnih."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Logika za nove zapise
|
||||
if not self.pk:
|
||||
self.trenutni_kilometri = self.pocetni_kilometri
|
||||
|
||||
self.registracija = self.registracija.upper().replace(" ", "")
|
||||
|
||||
# Pozivamo clean() prije spremanja (Django to ne radi automatski u save())
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.naziv} ({self.registracija})"
|
||||
|
||||
class Stroj(models.Model):
|
||||
TIP_STROJA = [
|
||||
('dizalica_toranj', 'Toranjska dizalica'),
|
||||
('dizalica_auto', 'Autodizalica'),
|
||||
('vilicar', 'Viličar'),
|
||||
('platforma', 'Radna platforma'),
|
||||
]
|
||||
|
||||
# Poveznica s kupcem: Jedan kupac može imati mnogo strojeva
|
||||
# --- RJEŠENJE ZA LOOP: Koristimo string 'kupci.Kupac' ---
|
||||
vlasnik = models.ForeignKey(
|
||||
'kupci.Kupac',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='strojevi',
|
||||
verbose_name="Vlasnik/Kupac"
|
||||
)
|
||||
|
||||
naziv = models.CharField(max_length=100, help_text="Npr. Liebherr LTM 1030")
|
||||
serijski_broj = models.CharField(max_length=50, unique=True)
|
||||
marka = models.CharField(max_length=50, blank=True)
|
||||
model_stroja = models.CharField(max_length=50, blank=True)
|
||||
godina_proizvodnje = models.PositiveIntegerField(null=True, blank=True)
|
||||
tip = models.CharField(max_length=30, choices=TIP_STROJA)
|
||||
|
||||
# Podaci za održavanje
|
||||
radni_sati = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
registracija = models.CharField(max_length=20, blank=True, null=True, help_text="Za autodizalice i vozila")
|
||||
datum_zadnjeg_atesta = models.DateField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Stroj"
|
||||
verbose_name_plural = "Strojevi"
|
||||
ordering = ['marka', 'model_stroja']
|
||||
|
||||
def __str__(self):
|
||||
# Mala nadogradnja da ne vraća prazan string ako marka/model nedostaju
|
||||
oznaka = f"{self.marka} {self.model_stroja}".strip() or self.naziv
|
||||
return f"{oznaka} (S/N: {self.serijski_broj})"
|
||||
115
001.BACKEND/fleet/serializers.py
Normal file
115
001.BACKEND/fleet/serializers.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Stroj, Vozilo
|
||||
|
||||
|
||||
class StrojShortSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Kraća verzija serijalizatora za brze liste ili dropdown izbornike.
|
||||
"""
|
||||
class Meta:
|
||||
model = Stroj
|
||||
fields = ['id', 'naziv', 'serijski_broj', 'registracija']
|
||||
|
||||
class StrojSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Glavni serializer za strojeve/dizalice.
|
||||
"""
|
||||
# Prikazuje naziv vlasnika (kupca) umjesto samo ID-a
|
||||
# (source='vlasnik.naziv' jer je u modelu ForeignKey nazvan 'vlasnik')
|
||||
vlasnik_naziv = serializers.ReadOnlyField(source='vlasnik.naziv')
|
||||
|
||||
# get_tip_display() pretvara 'dizalica_auto' u 'Autodizalica' (čitljivije za UI)
|
||||
tip_human_readable = serializers.CharField(source='get_tip_display', read_only=True)
|
||||
|
||||
radni_nalozi = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Stroj
|
||||
fields = [
|
||||
'id',
|
||||
'vlasnik',
|
||||
'vlasnik_naziv',
|
||||
'naziv',
|
||||
'marka',
|
||||
'model_stroja',
|
||||
'serijski_broj',
|
||||
'tip',
|
||||
'tip_human_readable',
|
||||
'godina_proizvodnje',
|
||||
'radni_sati',
|
||||
'registracija',
|
||||
'datum_zadnjeg_atesta',
|
||||
'radni_nalozi', # Novo polje
|
||||
]
|
||||
|
||||
def get_radni_nalozi(self, obj):
|
||||
# Lokalni import rješava kružni import error s operations aplikacijom
|
||||
from operations.serializers import RadniNalogSerializer
|
||||
|
||||
# Dohvaćamo radne naloge i sortiramo ih tako da najnoviji budu prvi
|
||||
queryset = obj.radni_nalozi.all().order_by('-datum_kreiranja')
|
||||
|
||||
# Vraćamo serijalizirane podatke
|
||||
return RadniNalogSerializer(queryset, many=True, context=self.context).data
|
||||
|
||||
class VoziloSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Osnovni serializer koji se koristi za Create/Update operacije.
|
||||
"""
|
||||
class Meta:
|
||||
model = Vozilo
|
||||
fields = '__all__'
|
||||
|
||||
class VoziloListaSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer za brzi prikaz u tablicama.
|
||||
"""
|
||||
# Ljudski čitljiv status (npr. 'Na Servisu' umjesto 'servis')
|
||||
status_prikaz = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Vozilo
|
||||
fields = ['id', 'naziv', 'registracija', 'trenutni_kilometri', 'status', 'status_prikaz']
|
||||
|
||||
class VoziloListaSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer za brzi prikaz u tablicama.
|
||||
"""
|
||||
# Ljudski čitljiv status (npr. 'Na Servisu' umjesto 'servis')
|
||||
status_prikaz = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Vozilo
|
||||
fields = ['id', 'naziv', 'registracija', 'trenutni_kilometri', 'status', 'status_prikaz']
|
||||
|
||||
class VoziloDetaljiSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer s dodatnom statistikom za detaljni prikaz vozila.
|
||||
"""
|
||||
ukupno_predeno = serializers.SerializerMethodField()
|
||||
status_prikaz = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
putni_nalozi = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Vozilo
|
||||
fields = [
|
||||
'id',
|
||||
'naziv',
|
||||
'registracija',
|
||||
'pocetni_kilometri',
|
||||
'trenutni_kilometri',
|
||||
'ukupno_predeno',
|
||||
'status',
|
||||
'status_prikaz',
|
||||
'putni_nalozi',
|
||||
]
|
||||
|
||||
def get_ukupno_predeno(self, obj):
|
||||
"""Izračunava razliku između trenutnih i početnih kilometara"""
|
||||
return obj.trenutni_kilometri - obj.pocetni_kilometri
|
||||
|
||||
def get_putni_nalozi(self, obj):
|
||||
from operations.serializers import PutniNalogSerializer
|
||||
queryset = obj.putni_nalozi.all()
|
||||
return PutniNalogSerializer(queryset, many=True, context=self.context).data
|
||||
3
001.BACKEND/fleet/tests.py
Normal file
3
001.BACKEND/fleet/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
001.BACKEND/fleet/urls.py
Normal file
11
001.BACKEND/fleet/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import StrojViewSet, VoziloViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'strojevi', StrojViewSet, basename='stroj')
|
||||
router.register(r'vozila', VoziloViewSet, basename='vozilo')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
72
001.BACKEND/fleet/views.py
Normal file
72
001.BACKEND/fleet/views.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from rest_framework import viewsets, filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from .models import Stroj, Vozilo
|
||||
from .serializers import (
|
||||
VoziloSerializer,
|
||||
VoziloListaSerializer,
|
||||
VoziloDetaljiSerializer,
|
||||
StrojSerializer
|
||||
)
|
||||
|
||||
class StrojViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet za pregled, kreiranje i uređivanje strojeva i dizalica.
|
||||
"""
|
||||
# select_related radi SQL JOIN i povlači podatke o kupcu odmah
|
||||
queryset = Stroj.objects.all().select_related('vlasnik').prefetch_related('radni_nalozi')
|
||||
serializer_class = StrojSerializer
|
||||
|
||||
# Dodajemo mogućnosti filtriranja, pretrage i sortiranja
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter
|
||||
]
|
||||
|
||||
# Omogućuje filtriranje strojeva po vlasniku ili tipu
|
||||
# npr. /api/strojevi/?vlasnik=1 ili /api/strojevi/?tip=dizalica_auto
|
||||
filterset_fields = ['vlasnik', 'tip']
|
||||
|
||||
# Omogućuje pretragu po nazivu, marki ili serijskom broju
|
||||
search_fields = ['naziv', 'marka', 'serijski_broj', 'registracija']
|
||||
|
||||
# Omogućuje sortiranje po radnim satima ili godini proizvodnje
|
||||
ordering_fields = ['radni_sati', 'godina_proizvodnje', 'datum_zadnjeg_atesta']
|
||||
ordering = ['-datum_zadnjeg_atesta'] # Defaultno prikazuje one koji su zadnji atestirani
|
||||
|
||||
class VoziloViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet za pregled, kreiranje i uređivanje voznog parka.
|
||||
Usklađen sa StrojViewSet arhitekturom.
|
||||
"""
|
||||
queryset = Vozilo.objects.all()
|
||||
|
||||
# Postavljanje filter backenda (isto kao u StrojViewSet)
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter
|
||||
]
|
||||
|
||||
# Omogućuje filtriranje vozila po statusu
|
||||
# npr. /api/fleet/vozila/?status=u_radu
|
||||
filterset_fields = ['status']
|
||||
|
||||
# Omogućuje pretragu po nazivu i registraciji
|
||||
search_fields = ['naziv', 'registracija']
|
||||
|
||||
# Omogućuje sortiranje po kilometrima ili statusu
|
||||
ordering_fields = ['trenutni_kilometri', 'status']
|
||||
ordering = ['naziv'] # Defaultno sortiranje po abecedi naziva
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
Dinamički odabir serializera za optimalne performanse.
|
||||
"""
|
||||
if self.action == 'list':
|
||||
return VoziloListaSerializer
|
||||
if self.action == 'retrieve':
|
||||
return VoziloDetaljiSerializer
|
||||
|
||||
# Default za POST/PUT/PATCH
|
||||
return VoziloSerializer
|
||||
0
001.BACKEND/kalendar/__init__.py
Normal file
0
001.BACKEND/kalendar/__init__.py
Normal file
81
001.BACKEND/kalendar/admin.py
Normal file
81
001.BACKEND/kalendar/admin.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import Dogadaj
|
||||
|
||||
@admin.register(Dogadaj)
|
||||
class DogadajAdmin(admin.ModelAdmin):
|
||||
# 1. Postavke liste (Dashboard pregled)
|
||||
list_display = ('prikaz_naslova', 'tip_ikona', 'pocetak', 'kraj', 'get_izvrsitelj', 'link_na_rn')
|
||||
list_filter = ('tip', 'pocetak', 'serviser_u_kalendaru')
|
||||
search_fields = ('naslov', 'opis', 'radni_nalog__broj_naloga', 'klijent__naziv')
|
||||
ordering = ('-pocetak',)
|
||||
|
||||
# 2. Optimizacija baze (Spriječava N+1 problem zbog @property-ja)
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related(
|
||||
'radni_nalog__izvrsitelj',
|
||||
'serviser_u_kalendaru',
|
||||
'klijent',
|
||||
'vozilo'
|
||||
)
|
||||
|
||||
# --- CUSTOM METODE ZA PRIKAZ ---
|
||||
|
||||
def prikaz_naslova(self, obj):
|
||||
"""Bojanje naslova prema tipu za brzu vizualnu orijentaciju"""
|
||||
boje = {
|
||||
'servis': '#2980b9', # Plava
|
||||
'isporuka': '#27ae60', # Zelena
|
||||
'sastanak': '#f39c12', # Narančasta
|
||||
'biljeska': '#7f8c8d', # Siva
|
||||
}
|
||||
boja = boje.get(obj.tip, 'black')
|
||||
return format_html('<span style="color: {}; font-weight: bold;">{}</span>', boja, obj.naslov)
|
||||
prikaz_naslova.short_description = "Naslov"
|
||||
|
||||
def tip_ikona(self, obj):
|
||||
"""Dodaje malu ikonu uz tip događaja"""
|
||||
ikone = {
|
||||
'servis': '🛠️',
|
||||
'isporuka': '🏗️',
|
||||
'sastanak': '🤝',
|
||||
'biljeska': '📝',
|
||||
}
|
||||
return format_html('{} {}', ikone.get(obj.tip, '📅'), obj.get_tip_display())
|
||||
tip_ikona.short_description = "Tip"
|
||||
|
||||
def get_izvrsitelj(self, obj):
|
||||
"""Prikazuje izvršitelja koristeći logiku iz model @property"""
|
||||
korisnik = obj.izvrsitelj
|
||||
if korisnik:
|
||||
return korisnik.get_full_name() or korisnik.username
|
||||
return format_html('<i style="color: #999;">Nije dodijeljeno</i>')
|
||||
get_izvrsitelj.short_description = "Izvršitelj (Serviser)"
|
||||
|
||||
def link_na_rn(self, obj):
|
||||
"""Prikazuje broj radnog naloga ako postoji"""
|
||||
if obj.radni_nalog:
|
||||
return format_html('<b>#{}</b>', obj.radni_nalog.broj_naloga)
|
||||
return "-"
|
||||
link_na_rn.short_description = "Radni Nalog"
|
||||
|
||||
# 3. Organizacija polja u formi (Fieldsets)
|
||||
fieldsets = (
|
||||
('Glavne informacije', {
|
||||
'fields': (('naslov', 'tip'), 'opis')
|
||||
}),
|
||||
('Vrijeme održavanja', {
|
||||
'fields': (('pocetak', 'kraj'),)
|
||||
}),
|
||||
('Poveznice sustava', {
|
||||
'fields': ('radni_nalog', 'klijent', 'vozilo'),
|
||||
'description': "Povežite događaj s klijentom ili radnim nalogom za bolju evidenciju."
|
||||
}),
|
||||
('Odgovornost', {
|
||||
'fields': ('serviser_u_kalendaru',),
|
||||
'description': "NAPOMENA: Ako je povezan Radni Nalog, sustav ignorira ovo polje i koristi servisera s naloga."
|
||||
}),
|
||||
)
|
||||
|
||||
# Postavljanje radnog naloga kao readonly ako želiš spriječiti ručno mijenjanje veza
|
||||
# readonly_fields = ('radni_nalog',)
|
||||
5
001.BACKEND/kalendar/apps.py
Normal file
5
001.BACKEND/kalendar/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class KalendarConfig(AppConfig):
|
||||
name = 'kalendar'
|
||||
61
001.BACKEND/kalendar/models.py
Normal file
61
001.BACKEND/kalendar/models.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
class Dogadaj(models.Model):
|
||||
TIP_DOGADAJA = [
|
||||
('servis', 'Radni Nalog / Servis'),
|
||||
('isporuka', 'Isporuka Stroja'),
|
||||
('sastanak', 'Sastanak s kupcem'),
|
||||
('biljeska', 'Interna bilješka'),
|
||||
]
|
||||
|
||||
naslov = models.CharField(max_length=200)
|
||||
opis = models.TextField(blank=True)
|
||||
tip = models.CharField(max_length=20, choices=TIP_DOGADAJA, default='biljeska')
|
||||
|
||||
pocetak = models.DateTimeField()
|
||||
kraj = models.DateTimeField()
|
||||
|
||||
# Automatsko praćenje izmjena samog događaja u kalendaru
|
||||
datum_kreiranja = models.DateTimeField(auto_now_add=True)
|
||||
datum_azuriranja = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Veze
|
||||
klijent = models.ForeignKey('kupci.Kupac', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
vozilo = models.ForeignKey('fleet.Vozilo', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
radni_nalog = models.OneToOneField(
|
||||
'operations.RadniNalog',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='kalendar_termin'
|
||||
)
|
||||
|
||||
# Serviser koji je dodijeljen direktno u kalendaru (za sastanke/bilješke)
|
||||
# Ako postoji radni_nalog, koristit ćemo izvrsitelja s naloga
|
||||
serviser_u_kalendaru = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='dodatni_zadaci'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Kalendar"
|
||||
verbose_name_plural = "Kalendar"
|
||||
ordering = ['pocetak']
|
||||
|
||||
@property
|
||||
def izvrsitelj(self):
|
||||
"""
|
||||
Pametna metoda koja vraća servisera:
|
||||
1. Ako je termin vezan za Radni Nalog, uzmi njegovog izvršitelja.
|
||||
2. Ako nije, uzmi servisera upisanog u kalendar.
|
||||
"""
|
||||
if self.radni_nalog:
|
||||
return self.radni_nalog.izvrsitelj
|
||||
return self.serviser_u_kalendaru
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.naslov} - {self.izvrsitelj}"
|
||||
46
001.BACKEND/kalendar/serializers.py
Normal file
46
001.BACKEND/kalendar/serializers.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Dogadaj
|
||||
from operations.serializers import RadniNalogListaSerializer
|
||||
|
||||
class DogadajSerializer(serializers.ModelSerializer):
|
||||
# 1. Mapiranje za standardne kalendarske knjižnice (FullCalendar traži title, start, end)
|
||||
title = serializers.CharField(source='naslov')
|
||||
start = serializers.DateTimeField(source='pocetak')
|
||||
end = serializers.DateTimeField(source='kraj')
|
||||
|
||||
# 2. Dinamički podaci (korištenje @property-ja i Choice polja)
|
||||
izvrsitelj_ime = serializers.SerializerMethodField()
|
||||
tip_display = serializers.CharField(source='get_tip_display', read_only=True)
|
||||
|
||||
# 3. Dodatne informacije za UI
|
||||
boja = serializers.SerializerMethodField()
|
||||
je_radni_nalog = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Dogadaj
|
||||
fields = [
|
||||
'id', 'title', 'start', 'end', 'tip', 'tip_display',
|
||||
'opis', 'izvrsitelj_ime', 'boja', 'je_radni_nalog',
|
||||
'klijent', 'vozilo', 'radni_nalog'
|
||||
]
|
||||
|
||||
def get_izvrsitelj_ime(self, obj):
|
||||
"""Dohvaća ime preko tvog @property izvrsitelj u modelu"""
|
||||
korisnik = obj.izvrsitelj # Poziva @property iz models.py
|
||||
if korisnik:
|
||||
return korisnik.get_full_name() or korisnik.username
|
||||
return "Nije dodijeljeno"
|
||||
|
||||
def get_boja(self, obj):
|
||||
"""Dodjeljuje boju ovisno o tipu događaja za vizualni kalendar"""
|
||||
boje = {
|
||||
'servis': '#3498db', # Plava
|
||||
'isporuka': '#2ecc71', # Zelena
|
||||
'sastanak': '#f1c40f', # Žuta
|
||||
'biljeska': '#95a5a6', # Siva
|
||||
}
|
||||
return boje.get(obj.tip, '#34495e')
|
||||
|
||||
def get_je_radni_nalog(self, obj):
|
||||
"""Pomaže frontendu da zna treba li prikazati link na radni nalog"""
|
||||
return obj.radni_nalog is not None
|
||||
52
001.BACKEND/kalendar/services.py
Normal file
52
001.BACKEND/kalendar/services.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import Dogadaj
|
||||
|
||||
def kreiraj_kalendarski_unos(radni_nalog):
|
||||
"""
|
||||
Kreira termin u kalendaru na temelju novog radnog naloga.
|
||||
"""
|
||||
pocetak = timezone.now()
|
||||
kraj = pocetak + timedelta(hours=2)
|
||||
|
||||
# DOHVAĆANJE VOZILA: Budući da vozilo više nije na Radnom nalogu,
|
||||
# moramo ga izvući iz povezanog Putnog naloga (ako postoji).
|
||||
vozilo_obj = None
|
||||
vozilo_str = "Nije definirano"
|
||||
|
||||
if radni_nalog.putni_nalog and radni_nalog.putni_nalog.vozilo:
|
||||
vozilo_obj = radni_nalog.putni_nalog.vozilo
|
||||
vozilo_str = str(vozilo_obj)
|
||||
|
||||
# Kreiramo Dogadaj
|
||||
dogadaj = Dogadaj.objects.create(
|
||||
naslov=f"RN: {radni_nalog.broj_naloga} | {radni_nalog.klijent.naziv}",
|
||||
opis=f"Vozilo: {vozilo_str}\nOpis kvara: {radni_nalog.opis_kvara}",
|
||||
tip='servis',
|
||||
pocetak=pocetak,
|
||||
kraj=kraj,
|
||||
klijent=radni_nalog.klijent,
|
||||
# Ovdje spremamo instancu vozila u polje modela Dogadaj
|
||||
vozilo=vozilo_obj,
|
||||
radni_nalog=radni_nalog,
|
||||
serviser_u_kalendaru=radni_nalog.izvrsitelj
|
||||
)
|
||||
|
||||
return dogadaj
|
||||
|
||||
def azuriraj_kalendarski_unos(radni_nalog):
|
||||
"""
|
||||
Sinkronizira podatke u kalendaru koristeći metodu modela.
|
||||
"""
|
||||
try:
|
||||
# Pokušaj pronaći postojeći događaj preko related_name 'kalendar_termin'
|
||||
dogadaj = radni_nalog.kalendar_termin
|
||||
dogadaj.sync_with_radni_nalog()
|
||||
except Exception:
|
||||
# Ako ne postoji (npr. nalog je stariji od uvođenja kalendara), kreiraj ga
|
||||
from .services import kreiraj_kalendarski_unos
|
||||
kreiraj_kalendarski_unos(radni_nalog)
|
||||
|
||||
def obrisi_kalendarski_unos(radni_nalog):
|
||||
if hasattr(radni_nalog, 'kalendar_termin'):
|
||||
radni_nalog.kalendar_termin.delete()
|
||||
3
001.BACKEND/kalendar/tests.py
Normal file
3
001.BACKEND/kalendar/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
17
001.BACKEND/kalendar/urls.py
Normal file
17
001.BACKEND/kalendar/urls.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import DogadajViewSet, MojRasporedView
|
||||
|
||||
# Kreiramo router i registriramo naš ViewSet za kalendarske događaje
|
||||
router = DefaultRouter()
|
||||
router.register(r'dogadaji', DogadajViewSet, basename='dogadaji')
|
||||
|
||||
# Definiramo URL obrasce
|
||||
urlpatterns = [
|
||||
# Sve CRUD rute koje router generira (npr. /kalendar/dogadaji/)
|
||||
path('', include(router.urls)),
|
||||
|
||||
# Specijalna ruta za "današnji raspored" servisera
|
||||
# Dostupna na: /kalendar/moj-raspored/
|
||||
path('moj-raspored/', MojRasporedView.as_view(), name='moj-raspored'),
|
||||
]
|
||||
55
001.BACKEND/kalendar/views.py
Normal file
55
001.BACKEND/kalendar/views.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from rest_framework import viewsets, generics, filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from .models import Dogadaj
|
||||
from .serializers import DogadajSerializer
|
||||
|
||||
class DogadajViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
Glavni API za kalendar. Podržava pregled svih termina,
|
||||
drag-and-drop (patch) i kreiranje novih bilješki.
|
||||
"""
|
||||
# VAŽNO: select_related('radni_nalog__izvrsitelj') je ključan
|
||||
# jer tvoj model koristi @property izvrsitelj.
|
||||
# Bez ovoga, svaki termin u kalendaru bi radio poseban upit u bazu.
|
||||
queryset = Dogadaj.objects.all().select_related(
|
||||
'radni_nalog__izvrsitelj',
|
||||
'radni_nalog__klijent',
|
||||
'klijent',
|
||||
'vozilo',
|
||||
'serviser_u_kalendaru'
|
||||
)
|
||||
serializer_class = DogadajSerializer
|
||||
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ['tip', 'klijent', 'vozilo']
|
||||
ordering = ['pocetak']
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Opcionalno: Ako želiš da serviseri vide samo svoje termine u kalendaru,
|
||||
otkomentiraj donji dio. Ako dispečer vidi sve, ostavi kako jest.
|
||||
"""
|
||||
qs = super().get_queryset()
|
||||
# if not self.request.user.is_staff:
|
||||
# return qs.filter(Q(radni_nalog__izvrsitelj=self.request.user) | Q(serviser_u_kalendaru=self.request.user))
|
||||
return qs
|
||||
|
||||
class MojRasporedView(generics.ListAPIView):
|
||||
"""
|
||||
Endpoint namijenjen mobilnoj aplikaciji: "Moji zadaci za danas".
|
||||
Pristup: GET /kalendar/api/moj-raspored/
|
||||
"""
|
||||
serializer_class = DogadajSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
danas = timezone.now().date()
|
||||
|
||||
# Filtriramo samo ono što je dodijeljeno ulogiranom korisniku za danas
|
||||
return Dogadaj.objects.filter(
|
||||
Q(pocetak__date=danas) | Q(kraj__date=danas)
|
||||
).filter(
|
||||
Q(radni_nalog__izvrsitelj=user) | Q(serviser_u_kalendaru=user)
|
||||
).select_related('radni_nalog', 'klijent', 'vozilo')
|
||||
0
001.BACKEND/kupci/__init__.py
Normal file
0
001.BACKEND/kupci/__init__.py
Normal file
41
001.BACKEND/kupci/admin.py
Normal file
41
001.BACKEND/kupci/admin.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.contrib import admin
|
||||
from .models import Kupac
|
||||
|
||||
@admin.register(Kupac)
|
||||
class KupacAdmin(admin.ModelAdmin):
|
||||
# Polja koja se vide u glavnoj tablici
|
||||
list_display = ('naziv', 'oib', 'grad', 'telefon', 'prikaz_broja_strojeva', 'aktivno')
|
||||
|
||||
# Brzo uređivanje "aktivno" statusa direktno iz tablice
|
||||
list_editable = ('aktivno',)
|
||||
|
||||
# Filtriranje s desne strane
|
||||
list_filter = ('tip', 'aktivno', 'grad')
|
||||
|
||||
# Pretraga po ključnim poljima
|
||||
# OBAVEZNO dodaj search_fields kako bi autocomplete radio u fleet i nalozi
|
||||
search_fields = ('naziv', 'oib', 'email', 'grad')
|
||||
|
||||
# Organizacija forme za unos
|
||||
fieldsets = (
|
||||
('Osnovni podaci', {
|
||||
'fields': (('naziv', 'tip'), 'oib')
|
||||
}),
|
||||
('Kontakt i lokacija', {
|
||||
'fields': ('email', 'telefon', 'adresa', ('grad', 'postanski_broj'))
|
||||
}),
|
||||
('Dodatno', {
|
||||
'fields': ('napomena', 'aktivno'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
# Čitanje sistemskih podataka bez mogućnosti izmjene
|
||||
readonly_fields = ('datum_kreiranja',)
|
||||
|
||||
def prikaz_broja_strojeva(self, obj):
|
||||
"""Prikazuje broj strojeva direktno u listi kupaca"""
|
||||
# Koristimo property koji si već definirao u modelu
|
||||
return obj.broj_strojeva
|
||||
|
||||
prikaz_broja_strojeva.short_description = 'Strojevi'
|
||||
5
001.BACKEND/kupci/apps.py
Normal file
5
001.BACKEND/kupci/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class KupciConfig(AppConfig):
|
||||
name = 'kupci'
|
||||
0
001.BACKEND/kupci/management/__init__.py
Normal file
0
001.BACKEND/kupci/management/__init__.py
Normal file
0
001.BACKEND/kupci/management/commands/__init__.py
Normal file
0
001.BACKEND/kupci/management/commands/__init__.py
Normal file
8
001.BACKEND/kupci/management/commands/hello_world.py
Normal file
8
001.BACKEND/kupci/management/commands/hello_world.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from typing import Any
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args: Any, **options: Any):
|
||||
print("hello world")
|
||||
55
001.BACKEND/kupci/management/commands/populate_kupci.py
Normal file
55
001.BACKEND/kupci/management/commands/populate_kupci.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import random
|
||||
from django.core.management.base import BaseCommand
|
||||
from faker import Faker
|
||||
from kupci.models import Kupac
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Popunjava bazu podataka s testnim kupcima'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'total',
|
||||
type=int,
|
||||
help='Broj kupaca koje želiš kreirati',
|
||||
default=10
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
total = kwargs['total']
|
||||
fake = Faker(['hr_HR'])
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING(f'🚀 Kreiram {total} kupaca...'))
|
||||
|
||||
created_count = 0
|
||||
for _ in range(total):
|
||||
tip = random.choice(['pravno', 'fizicko'])
|
||||
|
||||
if tip == 'pravno':
|
||||
naziv = fake.company()
|
||||
else:
|
||||
naziv = fake.name()
|
||||
|
||||
# Generiramo OIB (nasumičnih 11 znamenki)
|
||||
oib = "".join([str(random.randint(0, 9)) for _ in range(11)])
|
||||
|
||||
try:
|
||||
# Koristimo get_or_create ili create da izbjegnemo duplikate OIB-a
|
||||
# ako je polje unique=True u modelu
|
||||
Kupac.objects.create(
|
||||
naziv=naziv,
|
||||
oib=oib,
|
||||
email=fake.email(),
|
||||
telefon=fake.phone_number(),
|
||||
adresa=fake.street_address(),
|
||||
grad=fake.city(),
|
||||
postanski_broj=fake.postcode(),
|
||||
tip=tip,
|
||||
napomena="Automatski generiran testni kupac.",
|
||||
aktivno=True
|
||||
)
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
# Ako OIB slučajno bude isti, samo nastavi dalje
|
||||
continue
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'✅ Uspješno dodano {created_count} novih kupaca!'))
|
||||
49
001.BACKEND/kupci/models.py
Normal file
49
001.BACKEND/kupci/models.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from django.db import models
|
||||
|
||||
class Kupac(models.Model):
|
||||
TIP_KUPCA = [
|
||||
('pravno', 'Pravna osoba (Tvrtka)'),
|
||||
('fizicko', 'Fizička osoba'),
|
||||
]
|
||||
|
||||
naziv = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Ime i prezime ili puni naziv tvrtke"
|
||||
)
|
||||
oib = models.CharField(
|
||||
max_length=11,
|
||||
unique=True,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="OIB"
|
||||
)
|
||||
email = models.EmailField(blank=True)
|
||||
telefon = models.CharField(max_length=50, blank=True)
|
||||
adresa = models.CharField(max_length=255, blank=True)
|
||||
grad = models.CharField(max_length=100, blank=True)
|
||||
postanski_broj = models.CharField(max_length=10, blank=True)
|
||||
|
||||
tip = models.CharField(
|
||||
max_length=10,
|
||||
choices=TIP_KUPCA,
|
||||
default='pravno'
|
||||
)
|
||||
|
||||
napomena = models.TextField(blank=True, help_text="Interni podaci o kupcu")
|
||||
|
||||
# Sistemski podaci
|
||||
datum_kreiranja = models.DateTimeField(auto_now_add=True)
|
||||
aktivno = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Kupac"
|
||||
verbose_name_plural = "Kupci"
|
||||
ordering = ['naziv']
|
||||
|
||||
def __str__(self):
|
||||
return self.naziv
|
||||
|
||||
@property
|
||||
def broj_strojeva(self):
|
||||
"""Vraća broj strojeva povezanih s ovim kupcem"""
|
||||
return self.strojevi.count()
|
||||
73
001.BACKEND/kupci/serializers.py
Normal file
73
001.BACKEND/kupci/serializers.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Kupac
|
||||
from fleet.models import Stroj
|
||||
|
||||
from fleet.serializers import StrojSerializer
|
||||
|
||||
class KupacSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer za listu kupaca.
|
||||
Fokus je na brzini i osnovnim kontaktnim podacima.
|
||||
"""
|
||||
# Izračunato polje iz modela (@property broj_strojeva)
|
||||
broj_strojeva = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Kupac
|
||||
fields = [
|
||||
'id',
|
||||
'naziv',
|
||||
'grad',
|
||||
'telefon',
|
||||
'email',
|
||||
'tip',
|
||||
'aktivno',
|
||||
'broj_strojeva' # Korisno za prikaz u tablici (npr. "5 strojeva")
|
||||
]
|
||||
|
||||
class KupacDetaljiSerializer(serializers.ModelSerializer):
|
||||
# Ugniježđeni strojevi - prikazujemo puni objekt, ne samo ID
|
||||
strojevi = StrojSerializer(many=True, read_only=True)
|
||||
|
||||
# Dodatna polja koja nisu u bazi, ali su korisna za Astro frontend
|
||||
ukupan_broj_strojeva = serializers.IntegerField(source='broj_strojeva', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Kupac
|
||||
fields = [
|
||||
'id',
|
||||
'naziv',
|
||||
'oib',
|
||||
'email',
|
||||
'telefon',
|
||||
'adresa',
|
||||
'grad',
|
||||
'postanski_broj',
|
||||
'tip',
|
||||
'napomena',
|
||||
'aktivno',
|
||||
'datum_kreiranja',
|
||||
'ukupan_broj_strojeva',
|
||||
'strojevi' # Lista svih strojeva s punim detaljima
|
||||
]
|
||||
|
||||
class KupacListaSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Optimizirani serializer za prikaz u tablici (listi).
|
||||
Vraća samo osnovne podatke potrebne za pregled svih klijenata.
|
||||
"""
|
||||
# Dohvaćamo property iz modela koji broji strojeve
|
||||
broj_strojeva = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Kupac
|
||||
fields = [
|
||||
'id',
|
||||
'naziv',
|
||||
'grad',
|
||||
'oib',
|
||||
'telefon',
|
||||
'tip',
|
||||
'aktivno',
|
||||
'broj_strojeva'
|
||||
]
|
||||
3
001.BACKEND/kupci/tests.py
Normal file
3
001.BACKEND/kupci/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
001.BACKEND/kupci/urls.py
Normal file
14
001.BACKEND/kupci/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import KupacViewSet
|
||||
|
||||
# Kreiramo router i registriramo naš ViewSet
|
||||
# 'r' ispred stringa označava raw string (dobra praksa za regex/putanje)
|
||||
router = DefaultRouter()
|
||||
router.register(r'svi', KupacViewSet, basename='kupac')
|
||||
|
||||
# Definiramo URL obrasce
|
||||
urlpatterns = [
|
||||
# Sve rute koje router generira (npr. /api/kupci/ i /api/kupci/{id}/)
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
35
001.BACKEND/kupci/views.py
Normal file
35
001.BACKEND/kupci/views.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from rest_framework import viewsets, filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from .models import Kupac
|
||||
from .serializers import KupacSerializer, KupacDetaljiSerializer, KupacListaSerializer
|
||||
|
||||
class KupacViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint koji omogućuje pregled, pretragu i uređivanje kupaca.
|
||||
"""
|
||||
# Optimizacija: prefetch_related smanjuje broj upita jer odmah vuče strojeve
|
||||
queryset = Kupac.objects.all().prefetch_related('strojevi')
|
||||
serializer_class = KupacSerializer
|
||||
|
||||
# Dodajemo filter backende
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter
|
||||
]
|
||||
|
||||
# 1. Točno filtriranje (npr. ?tip=pravno)
|
||||
filterset_fields = ['tip', 'aktivno', 'grad']
|
||||
|
||||
# 2. Pretraga (npr. ?search=gradnja)
|
||||
# Pretražuje naziv ili OIB
|
||||
search_fields = ['naziv', 'oib']
|
||||
|
||||
# 3. Sortiranje (npr. ?ordering=-datum_kreiranja)
|
||||
ordering_fields = ['naziv', 'datum_kreiranja']
|
||||
ordering = ['naziv'] # Defaultno sortiranje
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve': # Ako se gleda pojedinačni kupac
|
||||
return KupacDetaljiSerializer # Onaj s ugniježđenim strojevima
|
||||
return KupacListaSerializer # Obični bez strojeva
|
||||
22
001.BACKEND/manage.py
Normal file
22
001.BACKEND/manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
001.BACKEND/operations/__init__.py
Normal file
0
001.BACKEND/operations/__init__.py
Normal file
112
001.BACKEND/operations/admin.py
Normal file
112
001.BACKEND/operations/admin.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# src/operations/admin.py
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import RadniNalog, RadniNalogSlika, PutniNalog
|
||||
|
||||
# --- INLINES ---
|
||||
|
||||
class RadniNalogSlikaInline(admin.TabularInline):
|
||||
"""Omogućuje dodavanje više slika unutar samog radnog naloga."""
|
||||
model = RadniNalogSlika
|
||||
extra = 1
|
||||
fields = ('slika', 'opis')
|
||||
|
||||
class RadniNalogInline(admin.StackedInline):
|
||||
"""
|
||||
Omogućuje dodavanje i pregled radnih naloga direktno unutar Putnog naloga.
|
||||
Ovo je ključno za terenski rad (jedno putovanje -> više odrađenih strojeva).
|
||||
"""
|
||||
model = RadniNalog
|
||||
extra = 0
|
||||
autocomplete_fields = ['stroj', 'klijent', 'izvrsitelj']
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': (('broj_naloga', 'status'), ('klijent', 'stroj'), 'opis_kvara')
|
||||
}),
|
||||
)
|
||||
|
||||
# --- ADMIN REGISTRACIJE ---
|
||||
|
||||
@admin.register(PutniNalog)
|
||||
class PutniNalogAdmin(admin.ModelAdmin):
|
||||
list_display = ('broj_naloga', 'prikaz_vozila', 'korisnik', 'pocetna_km', 'zavrsna_km', 'status_marker')
|
||||
list_filter = ('status', 'datum_izdavanja', 'vozilo')
|
||||
search_fields = ('broj_naloga', 'vozilo__registracija', 'korisnik__last_name')
|
||||
autocomplete_fields = ['vozilo', 'korisnik']
|
||||
|
||||
# Inlines omogućuju da vidiš servise (RN) unutar putovanja (PN)
|
||||
inlines = [RadniNalogInline]
|
||||
|
||||
# pocetna_km je readonly jer se automatski povlači iz flote pri kreiranju
|
||||
readonly_fields = ('pocetna_km',)
|
||||
|
||||
fieldsets = (
|
||||
('Logistika puta', {
|
||||
'fields': (('broj_naloga', 'status'), ('vozilo', 'korisnik'), 'relacija', 'mjesto_odredista')
|
||||
}),
|
||||
('Kilometraža i vrijeme', {
|
||||
'fields': (('pocetna_km', 'zavrsna_km'), ('vrijeme_polaska', 'vrijeme_povratka')),
|
||||
'description': 'Završni kilometri će automatski ažurirati bazu vozila po zatvaranju naloga.'
|
||||
}),
|
||||
)
|
||||
|
||||
def prikaz_vozila(self, obj):
|
||||
return format_html('<b>{}</b>', obj.vozilo.registracija)
|
||||
prikaz_vozila.short_description = "Vozilo"
|
||||
|
||||
def status_marker(self, obj):
|
||||
color = 'green' if obj.status == 'ZAVRSEN' else 'orange'
|
||||
return format_html('<span style="color: {}; font-weight: bold;">{}</span>', color, obj.get_status_display())
|
||||
status_marker.short_description = "Status"
|
||||
|
||||
|
||||
@admin.register(RadniNalog)
|
||||
class RadniNalogAdmin(admin.ModelAdmin):
|
||||
# Prikazujemo stroj i putni nalog kako bismo znali gdje je servis odrađen
|
||||
list_display = ('prikaz_broja', 'datum_kreiranja', 'klijent', 'stroj', 'status_boja', 'povezani_pn')
|
||||
list_filter = ('status', 'datum_kreiranja', 'izvrsitelj', 'stroj__tip')
|
||||
|
||||
# Pretraga po serijskom broju stroja je ključna za servisere
|
||||
search_fields = (
|
||||
'broj_naloga',
|
||||
'klijent__naziv',
|
||||
'stroj__naziv',
|
||||
'stroj__serijski_broj'
|
||||
)
|
||||
|
||||
# Autocomplete za lakše biranje među stotinama strojeva i klijenata
|
||||
autocomplete_fields = ['klijent', 'stroj', 'putni_nalog', 'izvrsitelj']
|
||||
|
||||
inlines = [RadniNalogSlikaInline]
|
||||
|
||||
fieldsets = (
|
||||
('Subjekt servisa', {
|
||||
'fields': (('broj_naloga', 'status'), ('klijent', 'stroj'), ('izvrsitelj', 'putni_nalog'))
|
||||
}),
|
||||
('Opis i dokumentacija', {
|
||||
'fields': ('opis_kvara', 'slika_kvara', 'potpis_klijenta')
|
||||
}),
|
||||
)
|
||||
|
||||
def prikaz_broja(self, obj):
|
||||
return format_html('<b>RN-{}</b>', obj.broj_naloga)
|
||||
prikaz_broja.short_description = "Nalog"
|
||||
|
||||
def povezani_pn(self, obj):
|
||||
if obj.putni_nalog:
|
||||
return obj.putni_nalog.broj_naloga
|
||||
return "Nema (Radiona)"
|
||||
povezani_pn.short_description = "Putni nalog"
|
||||
|
||||
def status_boja(self, obj):
|
||||
boje = {
|
||||
'PLANIRANO': '#6b7280',
|
||||
'U_RADU': '#2563eb',
|
||||
'ZAVRSENO': '#10b981',
|
||||
}
|
||||
return format_html(
|
||||
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||||
boje.get(obj.status, 'black'),
|
||||
obj.get_status_display()
|
||||
)
|
||||
status_boja.short_description = "Status"
|
||||
5
001.BACKEND/operations/apps.py
Normal file
5
001.BACKEND/operations/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OperationsConfig(AppConfig):
|
||||
name = 'operations'
|
||||
@@ -0,0 +1,132 @@
|
||||
import random
|
||||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.core.files import File
|
||||
from operations.models import RadniNalog, RadniNalogSlika, PutniNalog
|
||||
from fleet.models import Vozilo, Stroj
|
||||
from kupci.models import Kupac
|
||||
from kalendar.services import kreiraj_kalendarski_unos
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Popunjava bazu s Putnim nalozima, Radnim nalozima i slikama'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('total_putovanja', type=int, help='Broj putnih naloga', default=3)
|
||||
parser.add_argument('--img', type=str, default='test_assets/kvar.jpg')
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
total_putovanja = kwargs['total_putovanja']
|
||||
test_img_path = kwargs['img']
|
||||
|
||||
kupci = Kupac.objects.prefetch_related('strojevi').all()
|
||||
vozila = list(Vozilo.objects.all())
|
||||
serviseri = list(User.objects.filter(is_staff=True))
|
||||
|
||||
if not kupci.exists() or not vozila or not serviseri:
|
||||
self.stdout.write(self.style.ERROR("Baza mora imati kupce, vozila i servisere (is_staff=True)."))
|
||||
return
|
||||
|
||||
# Provjera slike
|
||||
if not os.path.exists(test_img_path):
|
||||
self.stdout.write(self.style.WARNING(f"Testna slika {test_img_path} nije pronađena. Generiram bez slika."))
|
||||
test_img_path = None
|
||||
|
||||
opisi_kvarova = [
|
||||
"Godišnji periodični servis dizalice.",
|
||||
"Zamjena hidrauličnog crijeva i dopuna ulja.",
|
||||
"Kalibracija senzora opterećenja.",
|
||||
"Popravak daljinskog upravljača.",
|
||||
"Hitna intervencija: Stroj u kvaru."
|
||||
]
|
||||
|
||||
self.stdout.write(f'🚀 Generiranje {total_putovanja} putovanja...')
|
||||
success_count_rn = 0
|
||||
trenutna_godina = timezone.now().year
|
||||
|
||||
for i in range(total_putovanja):
|
||||
serviser = random.choice(serviseri)
|
||||
vozilo = random.choice(vozila)
|
||||
broj_pn = f"PN-{trenutna_godina}-{PutniNalog.objects.count() + 1:04d}"
|
||||
|
||||
# 1. Kreiraj PUTNI NALOG (u statusu OTVOREN)
|
||||
putni_nalog = PutniNalog.objects.create(
|
||||
broj_naloga=broj_pn,
|
||||
vozilo=vozilo,
|
||||
korisnik=serviser,
|
||||
relacija="Zagreb - Teren - Zagreb",
|
||||
mjesto_odredista="Teren",
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_LABEL(f"Otvoren {broj_pn} (Vozilo: {vozilo.registracija})"))
|
||||
|
||||
# 2. Kreiraj 1 do 3 RADNA NALOGA za ovaj putni nalog
|
||||
za_odraditi = random.randint(1, 3)
|
||||
kreirani_radni_nalozi = []
|
||||
|
||||
for j in range(za_odraditi):
|
||||
klijenti_sa_strojima = [k for k in kupci if k.strojevi.exists()]
|
||||
klijent = random.choice(klijenti_sa_strojima)
|
||||
stroj = random.choice(list(klijent.strojevi.all()))
|
||||
|
||||
broj_rn = f"{trenutna_godina}-{RadniNalog.objects.count() + 1:04d}"
|
||||
|
||||
try:
|
||||
nalog = RadniNalog(
|
||||
broj_naloga=broj_rn,
|
||||
klijent=klijent,
|
||||
stroj=stroj,
|
||||
putni_nalog=putni_nalog,
|
||||
opis_kvara=random.choice(opisi_kvarova),
|
||||
izvrsitelj=serviser,
|
||||
status=RadniNalog.StatusRadaChoices.PLANIRANO
|
||||
)
|
||||
|
||||
if test_img_path:
|
||||
with open(test_img_path, 'rb') as f:
|
||||
nalog.slika_kvara.save(f'rn_{broj_rn}.jpg', File(f), save=False)
|
||||
|
||||
nalog.save()
|
||||
kreirani_radni_nalozi.append(nalog)
|
||||
|
||||
# Dodatne slike u galeriju
|
||||
if test_img_path:
|
||||
for k in range(random.randint(1, 2)):
|
||||
with open(test_img_path, 'rb') as f:
|
||||
RadniNalogSlika.objects.create(
|
||||
radni_nalog=nalog,
|
||||
slika=File(f, name=f'gal_{broj_rn}_{k}.jpg'),
|
||||
opis=f"Detalj kvara {k+1}"
|
||||
)
|
||||
|
||||
# 5. Upis u kalendar
|
||||
kreiraj_kalendarski_unos(nalog)
|
||||
|
||||
success_count_rn += 1
|
||||
self.stdout.write(f" ✅ RN-{broj_rn} dodan u {broj_pn}")
|
||||
|
||||
except Exception as e:
|
||||
self.stderr.write(f" ❌ Greška kod RN-{broj_rn}: {e}")
|
||||
|
||||
# 3. NASUMIČNO ZATVARANJE (Simulacija završenog posla)
|
||||
# 50% šanse da ćemo u skripti odmah "završiti" ovaj put
|
||||
if random.random() > 0.5:
|
||||
try:
|
||||
# Prvo moramo zatvoriti sve radne naloge da bi PN prošao validaciju
|
||||
for rn in kreirani_radni_nalozi:
|
||||
rn.status = RadniNalog.StatusRadaChoices.ZAVRSENO
|
||||
rn.save()
|
||||
|
||||
# Dodajemo završne kilometre (uvijek više od početnih)
|
||||
putni_nalog.zavrsna_km = putni_nalog.pocetna_km + random.randint(50, 500)
|
||||
putni_nalog.status = PutniNalog.StatusChoices.ZAVRSEN
|
||||
putni_nalog.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f" 🏁 {broj_pn} automatski ZATVOREN. Kilometri vozila ažurirani."))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f" ⚠️ PN-{broj_pn} nije zatvoren: {e}"))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\nGotovo! Generirano {total_putovanja} PN i {success_count_rn} RN.'))
|
||||
220
001.BACKEND/operations/models.py
Normal file
220
001.BACKEND/operations/models.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import os
|
||||
from PIL import Image
|
||||
import io
|
||||
from django.db import models, transaction
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# --- FUNKCIJE ZA DINAMIČKE PUTANJE ---
|
||||
|
||||
def putanja_slike_naloga(instance, filename):
|
||||
# Kreira putanju: radni-nalozi/broj_naloga/originalni_naziv.ekstenzija
|
||||
return f'radni-nalozi/{instance.broj_naloga}/{filename}'
|
||||
|
||||
def putanja_galerije_naloga(instance, filename):
|
||||
# instance je objekt RadniNalogSlika koji ima ForeignKey na radni_nalog
|
||||
# Sprema u isti folder gdje je i glavna slika kvara
|
||||
return f'radni-nalozi/{instance.radni_nalog.broj_naloga}/galerija/{filename}'
|
||||
|
||||
|
||||
class PutniNalog(models.Model):
|
||||
class StatusChoices(models.TextChoices):
|
||||
OTVOREN = 'OTVOREN', 'Otvoren'
|
||||
ZAVRSEN = 'ZAVRSEN', 'Završen'
|
||||
|
||||
broj_naloga = models.CharField(max_length=50, unique=True)
|
||||
datum_izdavanja = models.DateField(default=timezone.now)
|
||||
|
||||
vozilo = models.ForeignKey(
|
||||
'fleet.Vozilo',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='putni_nalozi',
|
||||
verbose_name="Službeno vozilo"
|
||||
)
|
||||
|
||||
korisnik = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||
relacija = models.CharField(max_length=255)
|
||||
mjesto_odredista = models.CharField(max_length=255)
|
||||
|
||||
pocetna_km = models.PositiveIntegerField(
|
||||
editable=False,
|
||||
help_text="Automatski preuzeto iz modela Vozilo"
|
||||
)
|
||||
zavrsna_km = models.PositiveIntegerField(null=True, blank=True)
|
||||
|
||||
vrijeme_polaska = models.DateTimeField(default=timezone.now)
|
||||
vrijeme_povratka = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=15,
|
||||
choices=StatusChoices.choices,
|
||||
default=StatusChoices.OTVOREN
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Putni nalog"
|
||||
verbose_name_plural = "Putni nalozi"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.status == self.StatusChoices.ZAVRSEN:
|
||||
if self.zavrsna_km is None:
|
||||
raise ValidationError({'zavrsna_km': "Morate unijeti završne kilometre prije zatvaranja naloga."})
|
||||
|
||||
otvoreni_radni_nalozi = self.radni_nalozi.exclude(status='ZAVRSENO').exists()
|
||||
if otvoreni_radni_nalozi:
|
||||
raise ValidationError({
|
||||
'status': "Putni nalog ne može biti završen jer postoje povezani radni nalozi koji još nisu zatvoreni."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk:
|
||||
self.pocetna_km = self.vozilo.trenutni_kilometri
|
||||
|
||||
self.full_clean()
|
||||
|
||||
if self.status == self.StatusChoices.ZAVRSEN and self.zavrsna_km:
|
||||
with transaction.atomic():
|
||||
super().save(*args, **kwargs)
|
||||
v = self.vozilo
|
||||
v.trenutni_kilometri = self.zavrsna_km
|
||||
v.pocetni_kilometri = self.zavrsna_km
|
||||
v.save()
|
||||
else:
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.broj_naloga} - {self.vozilo.registracija}"
|
||||
|
||||
|
||||
class RadniNalog(models.Model):
|
||||
class StatusRadaChoices(models.TextChoices):
|
||||
PLANIRANO = 'PLANIRANO', 'Planirano'
|
||||
U_RADU = 'U_RADU', 'U radu'
|
||||
ZAVRSENO = 'ZAVRSENO', 'Završeno'
|
||||
|
||||
broj_naloga = models.CharField(max_length=20, unique=True, blank=True)
|
||||
|
||||
stroj = models.ForeignKey(
|
||||
'fleet.Stroj',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='radni_nalozi',
|
||||
verbose_name="Stroj na popravku"
|
||||
)
|
||||
|
||||
putni_nalog = models.ForeignKey(
|
||||
'PutniNalog',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='radni_nalozi'
|
||||
)
|
||||
|
||||
klijent = models.ForeignKey(
|
||||
'kupci.Kupac',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='radni_nalozi'
|
||||
)
|
||||
|
||||
opis_kvara = models.TextField()
|
||||
izvrsitelj = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='dodijeljeni_nalozi'
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=StatusRadaChoices.choices,
|
||||
default=StatusRadaChoices.PLANIRANO
|
||||
)
|
||||
|
||||
datum_kreiranja = models.DateTimeField(auto_now_add=True, verbose_name="Datum kreiranja")
|
||||
datum_azuriranja = models.DateTimeField(auto_now=True, verbose_name="Zadnja izmjena")
|
||||
slika_kvara = models.ImageField(upload_to=putanja_slike_naloga, null=True, blank=True)
|
||||
potpis_klijenta = models.ImageField(upload_to='potpisi/%Y/%m/', null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 1. AUTOMATSKO GENERIRANJE BROJA NALOGA
|
||||
if not self.broj_naloga:
|
||||
godina = timezone.now().year
|
||||
prefix = f"RN-{godina}-"
|
||||
|
||||
# Koristimo atomsku transakciju i select_for_update da spriječimo
|
||||
# da dva korisnika dobiju isti broj u istoj milisekundi
|
||||
with transaction.atomic():
|
||||
zadnji_nalog = RadniNalog.objects.select_for_update().filter(
|
||||
broj_naloga__startswith=prefix
|
||||
).order_by('-broj_naloga').first()
|
||||
|
||||
if zadnji_nalog:
|
||||
try:
|
||||
# RN-2026-0001 -> uzimamo "0001"
|
||||
zadnji_broj_str = zadnji_nalog.broj_naloga.split('-')[-1]
|
||||
novi_broj = int(zadnji_broj_str) + 1
|
||||
except (ValueError, IndexError):
|
||||
novi_broj = 1
|
||||
else:
|
||||
novi_broj = 1
|
||||
|
||||
self.broj_naloga = f"{prefix}{novi_broj:04d}"
|
||||
|
||||
# 2. OBRADA SLIKE
|
||||
if self.slika_kvara:
|
||||
try:
|
||||
self.slika_kvara = self.rescale_image(self.slika_kvara)
|
||||
except Exception as e:
|
||||
print(f"Greška pri obradi slike: {e}")
|
||||
|
||||
# 3. PRIMARNO SPREMANJE
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# 4. LOGIKA ZA PUTNI NALOG
|
||||
if self.putni_nalog and self.status == self.StatusRadaChoices.ZAVRSENO:
|
||||
pn = self.putni_nalog
|
||||
# Provjeravamo ima li nalog još uvijek otvorenih radnih naloga
|
||||
ostali_otvoreni = pn.radni_nalozi.exclude(
|
||||
id=self.id, # Isključujemo trenutni nalog jer je on upravo završen
|
||||
status=self.StatusRadaChoices.ZAVRSENO
|
||||
).exists()
|
||||
|
||||
if not ostali_otvoreni and pn.zavrsna_km:
|
||||
pn.status = 'ZAVRSEN'
|
||||
pn.save()
|
||||
|
||||
def rescale_image(self, image_field):
|
||||
img = Image.open(image_field)
|
||||
if img.height > 1200 or img.width > 1200:
|
||||
output_size = (1200, 1200)
|
||||
img.thumbnail(output_size)
|
||||
img_io = io.BytesIO()
|
||||
img_format = img.format if img.format else 'JPEG'
|
||||
img.save(img_io, format=img_format, quality=90)
|
||||
return ContentFile(img_io.getvalue(), name=image_field.name)
|
||||
return image_field
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Radni nalog"
|
||||
verbose_name_plural = "Radni nalozi"
|
||||
|
||||
def __str__(self):
|
||||
return f"RN-{self.broj_naloga} | {self.stroj.naziv}"
|
||||
|
||||
class RadniNalogSlika(models.Model):
|
||||
radni_nalog = models.ForeignKey(
|
||||
RadniNalog,
|
||||
related_name='slike',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
# POPRAVLJENO: uklonjeni navodnici oko funkcije
|
||||
slika = models.ImageField(upload_to=putanja_galerije_naloga, verbose_name="Slika")
|
||||
opis = models.CharField(max_length=100, blank=True, verbose_name="Kratki opis slike")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Dodatna slika naloga"
|
||||
verbose_name_plural = "Dodatne slike naloga"
|
||||
|
||||
def __str__(self):
|
||||
return f"Slika za nalog {self.radni_nalog.broj_naloga}"
|
||||
86
001.BACKEND/operations/serializers.py
Normal file
86
001.BACKEND/operations/serializers.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from rest_framework import serializers
|
||||
from .models import RadniNalog, RadniNalogSlika
|
||||
from kupci.serializers import KupacListaSerializer
|
||||
from fleet.serializers import VoziloListaSerializer, StrojSerializer
|
||||
|
||||
# --- POMOĆNI SERIALIZER ZA SLIKE ---
|
||||
class RadniNalogSlikaSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RadniNalogSlika
|
||||
fields = ['id', 'slika', 'opis']
|
||||
|
||||
# --- 1. LISTA SERIALIZER ---
|
||||
class RadniNalogListaSerializer(serializers.ModelSerializer):
|
||||
klijent_naziv = serializers.CharField(source='klijent.naziv', read_only=True)
|
||||
stroj_naziv = serializers.CharField(source='stroj.naziv', read_only=True)
|
||||
izvrsitelj_ime = serializers.CharField(source='izvrsitelj.get_full_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RadniNalog
|
||||
fields = [
|
||||
'id',
|
||||
'broj_naloga',
|
||||
'klijent_naziv',
|
||||
'stroj_naziv',
|
||||
'status',
|
||||
'izvrsitelj_ime',
|
||||
'datum_kreiranja'
|
||||
]
|
||||
|
||||
# --- 2. DETALJI SERIALIZER ---
|
||||
class RadniNalogDetaljiSerializer(serializers.ModelSerializer):
|
||||
klijent = KupacListaSerializer(read_only=True)
|
||||
stroj = StrojSerializer(read_only=True)
|
||||
slike = RadniNalogSlikaSerializer(many=True, read_only=True)
|
||||
|
||||
# VOZILO dohvaćamo preko putnog naloga
|
||||
vozilo = serializers.SerializerMethodField()
|
||||
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
izvrsitelj_ime = serializers.CharField(source='izvrsitelj.get_full_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RadniNalog
|
||||
fields = [
|
||||
'id', 'broj_naloga', 'klijent', 'stroj', 'putni_nalog', 'vozilo', 'opis_kvara',
|
||||
'izvrsitelj', 'izvrsitelj_ime', 'status', 'status_display',
|
||||
'datum_kreiranja', 'datum_azuriranja', 'slika_kvara',
|
||||
'potpis_klijenta', 'slike'
|
||||
]
|
||||
read_only_fields = ['datum_kreiranja', 'datum_azuriranja']
|
||||
|
||||
def get_vozilo(self, obj):
|
||||
# Ako je radni nalog vezan za putni nalog, vrati podatke o vozilu
|
||||
if obj.putni_nalog and obj.putni_nalog.vozilo:
|
||||
return {
|
||||
"id": obj.putni_nalog.vozilo.id,
|
||||
"naziv": obj.putni_nalog.vozilo.naziv,
|
||||
"registracija": obj.putni_nalog.vozilo.registracija,
|
||||
"trenutni_kilometri": obj.putni_nalog.vozilo.trenutni_kilometri
|
||||
}
|
||||
return None
|
||||
|
||||
# --- 3. DEFAULT SERIALIZER ---
|
||||
class RadniNalogSerializer(serializers.ModelSerializer):
|
||||
# Dozvoljavamo unos, ali nije obavezan ako ga backend generira
|
||||
broj_naloga = serializers.CharField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RadniNalog
|
||||
exclude = []
|
||||
|
||||
def validate_broj_naloga(self, value):
|
||||
# Provjera jedinstvenosti prije nego što dođe do baze
|
||||
if RadniNalog.objects.filter(broj_naloga=value).exists():
|
||||
raise serializers.ValidationError("Radni nalog s ovim brojem već postoji.")
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
# Tvoja postojeća validacija klijenta i stroja
|
||||
stroj = data.get('stroj')
|
||||
klijent = data.get('klijent')
|
||||
if stroj and klijent and stroj.vlasnik != klijent:
|
||||
raise serializers.ValidationError(
|
||||
{"stroj": "Odabrani stroj ne pripada odabranom klijentu."}
|
||||
)
|
||||
return data
|
||||
3
001.BACKEND/operations/tests.py
Normal file
3
001.BACKEND/operations/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
12
001.BACKEND/operations/urls.py
Normal file
12
001.BACKEND/operations/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import RadniNalogViewSet
|
||||
|
||||
# Kreiramo router i registriramo ViewSet za radne naloge
|
||||
router = DefaultRouter()
|
||||
router.register(r'radni-nalozi', RadniNalogViewSet, basename='radni-nalog')
|
||||
|
||||
urlpatterns = [
|
||||
# Uključujemo sve rute koje router generira
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
106
001.BACKEND/operations/views.py
Normal file
106
001.BACKEND/operations/views.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from rest_framework.decorators import action
|
||||
from django.utils import timezone
|
||||
from rest_framework import viewsets, filters, status # Dodan status
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from .models import RadniNalog, RadniNalogSlika, PutniNalog
|
||||
from rest_framework.response import Response
|
||||
from .serializers import (
|
||||
RadniNalogSerializer,
|
||||
RadniNalogListaSerializer,
|
||||
RadniNalogDetaljiSerializer
|
||||
)
|
||||
from kalendar.services import kreiraj_kalendarski_unos
|
||||
|
||||
class RadniNalogViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint za upravljanje radnim nalozima.
|
||||
Vozilo se sada filtrira i pretražuje preko Putnog Naloga.
|
||||
"""
|
||||
# 1. queryset: UKLONJEN 'vozilo' iz select_related
|
||||
queryset = RadniNalog.objects.all().select_related(
|
||||
'klijent', 'izvrsitelj', 'putni_nalog__vozilo'
|
||||
)
|
||||
|
||||
serializer_class = RadniNalogSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter
|
||||
]
|
||||
|
||||
# 2. filterset_fields: 'vozilo' zamijenjeno putanjom preko putnog naloga
|
||||
filterset_fields = ['status', 'izvrsitelj', 'klijent', 'putni_nalog__vozilo']
|
||||
|
||||
# 3. search_fields: pretraga ide preko putni_nalog__vozilo__registracija
|
||||
search_fields = [
|
||||
'broj_naloga',
|
||||
'klijent__naziv',
|
||||
'putni_nalog__vozilo__registracija',
|
||||
'putni_nalog__vozilo__naziv'
|
||||
]
|
||||
|
||||
ordering_fields = ['datum_kreiranja', 'status', 'broj_naloga']
|
||||
ordering = ['-datum_kreiranja']
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return RadniNalogListaSerializer
|
||||
if self.action == 'retrieve':
|
||||
return RadniNalogDetaljiSerializer
|
||||
return RadniNalogSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
# Optimizacija upita ovisno o akciji
|
||||
queryset = self.queryset
|
||||
if self.action == 'retrieve':
|
||||
queryset = queryset.prefetch_related('slike')
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# Postavljamo izvršitelja ako nije poslan
|
||||
if not serializer.validated_data.get('izvrsitelj'):
|
||||
radni_nalog = serializer.save(izvrsitelj=self.request.user)
|
||||
else:
|
||||
radni_nalog = serializer.save()
|
||||
|
||||
# Kalendar servis
|
||||
try:
|
||||
kreiraj_kalendarski_unos(radni_nalog)
|
||||
except Exception as e:
|
||||
print(f"Greška pri kreiranju kalendarskog zapisa: {e}")
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
|
||||
instance = serializer.instance
|
||||
|
||||
# Obrada višestrukih slika
|
||||
files = request.FILES.getlist('slike')
|
||||
for f in files:
|
||||
RadniNalogSlika.objects.create(radni_nalog=instance, slika=f)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='sljedeci-broj')
|
||||
def dohvati_sljedeci_broj(self, request):
|
||||
godina = timezone.now().year
|
||||
prefix = f"{godina}-"
|
||||
|
||||
zadnji_nalog = RadniNalog.objects.filter(
|
||||
broj_naloga__startswith=prefix
|
||||
).order_by('-broj_naloga').first()
|
||||
|
||||
if zadnji_nalog:
|
||||
try:
|
||||
zadnji_broj = int(zadnji_nalog.broj_naloga.split('-')[-1])
|
||||
novi_broj = zadnji_broj + 1
|
||||
except (ValueError, IndexError):
|
||||
novi_broj = 1
|
||||
else:
|
||||
novi_broj = 1
|
||||
|
||||
return Response({"broj_naloga": f"{prefix}{novi_broj:04d}"})
|
||||
BIN
001.BACKEND/requirements.txt
Normal file
BIN
001.BACKEND/requirements.txt
Normal file
Binary file not shown.
BIN
001.BACKEND/test_assets/kvar.jpg
Normal file
BIN
001.BACKEND/test_assets/kvar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
0
001.BACKEND/users/__init__.py
Normal file
0
001.BACKEND/users/__init__.py
Normal file
13
001.BACKEND/users/admin.py
Normal file
13
001.BACKEND/users/admin.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.contrib import admin
|
||||
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']
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
('Dodatni podaci', {'fields': ('telefon', 'oib', 'is_serviser')}),
|
||||
)
|
||||
|
||||
admin.site.register(CustomUser, CustomUserAdmin)
|
||||
5
001.BACKEND/users/apps.py
Normal file
5
001.BACKEND/users/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = 'users'
|
||||
18
001.BACKEND/users/models.py
Normal file
18
001.BACKEND/users/models.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
# 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
|
||||
|
||||
# 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})"
|
||||
7
001.BACKEND/users/serializers.py
Normal file
7
001.BACKEND/users/serializers.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from .models import CustomUser
|
||||
|
||||
class UserMeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser']
|
||||
3
001.BACKEND/users/tests.py
Normal file
3
001.BACKEND/users/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
001.BACKEND/users/urls.py
Normal file
10
001.BACKEND/users/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import UserViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', UserViewSet, basename='user')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
27
001.BACKEND/users/views.py
Normal file
27
001.BACKEND/users/views.py
Normal file
@@ -0,0 +1,27 @@
|
||||
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 import serializers
|
||||
from .models import CustomUser
|
||||
|
||||
class UserMeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser']
|
||||
|
||||
class UserViewSet(viewsets.ViewSet):
|
||||
# Akcija 'me' bit će dostupna na /api/users/me/
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
# 1. Provjera je li korisnik stvarno logiran (ima validan token)
|
||||
if 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
|
||||
)
|
||||
|
||||
# 2. Ako je logiran, normalno serijaliziraj
|
||||
serializer = UserMeSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
24
001.FRONTEND/.gitignore
vendored
Normal file
24
001.FRONTEND/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
4
001.FRONTEND/.vscode/extensions.json
vendored
Normal file
4
001.FRONTEND/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
001.FRONTEND/.vscode/launch.json
vendored
Normal file
11
001.FRONTEND/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
46
001.FRONTEND/README.md
Normal file
46
001.FRONTEND/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Astro Starter Kit: Basics
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template basics
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src
|
||||
│ ├── assets
|
||||
│ │ └── astro.svg
|
||||
│ ├── components
|
||||
│ │ └── Welcome.astro
|
||||
│ ├── layouts
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
25
001.FRONTEND/astro.config.mjs
Normal file
25
001.FRONTEND/astro.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import node from '@astrojs/node';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
// Ovo omogućuje dinamičke rute bez getStaticPaths
|
||||
output: 'server',
|
||||
|
||||
adapter: node({
|
||||
mode: 'standalone'
|
||||
}),
|
||||
|
||||
image: {
|
||||
// Dodaj domene s kojih Astro smije povlačiti i optimizirati slike
|
||||
domains: ['localhost', '127.0.0.1'],
|
||||
},
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
}
|
||||
});
|
||||
5838
001.FRONTEND/package-lock.json
generated
Normal file
5838
001.FRONTEND/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
001.FRONTEND/package.json
Normal file
22
001.FRONTEND/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "poslovanje",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.6",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"astro": "^6.1.9",
|
||||
"flowbite": "^4.0.1",
|
||||
"photoswipe": "^5.4.4",
|
||||
"tailwindcss": "^4.2.4"
|
||||
}
|
||||
}
|
||||
1
001.FRONTEND/src/assets/astro.svg
Normal file
1
001.FRONTEND/src/assets/astro.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
001.FRONTEND/src/assets/background.svg
Normal file
1
001.FRONTEND/src/assets/background.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
169
001.FRONTEND/src/components/AkcijePanel.astro
Normal file
169
001.FRONTEND/src/components/AkcijePanel.astro
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
// src/components/AkcijePanel.astro
|
||||
|
||||
import Button from './Button.astro';
|
||||
|
||||
interface Props {
|
||||
tip: 'dashboard' | 'radni-nalog' | 'stroj' | 'vlasnik';
|
||||
podaci: any;
|
||||
}
|
||||
|
||||
const { tip, podaci } = Astro.props;
|
||||
|
||||
// Pomoćna funkcija za formatiranje datuma
|
||||
const formatDate = (date: string) =>
|
||||
new Date(date).toLocaleDateString('hr-HR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
---
|
||||
|
||||
<aside class="space-y-6">
|
||||
<!-- SEKCIJA 1: GLAVNE AKCIJE (Uvijek vidljivo) -->
|
||||
|
||||
<div class="grid gap-3">
|
||||
{tip === 'dashboard' && (
|
||||
<>
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight italic px-4">
|
||||
Upravljanje
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<a href="/operativa/radni-nalozi/novi" class="group bg-blue-600 text-white p-6 rounded-[2rem] flex items-center gap-4 hover:bg-blue-700 transition-all shadow-xl shadow-blue-500/20 active:scale-95">
|
||||
<div class="bg-white text-blue-600 w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg group-hover:rotate-12 transition-transform shrink-0">
|
||||
<i class="fa-solid fa-plus text-xl"></i>
|
||||
</div>
|
||||
<div class="flex flex-col text-left">
|
||||
<span class="font-black uppercase text-xs tracking-widest leading-none">Novi nalog</span>
|
||||
<span class="text-[10px] opacity-70 mt-1 italic font-bold uppercase">Inicijaliziraj nalog</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/fleet/vozila" class="group bg-white dark:bg-gray-800 text-gray-900 dark:text-white p-6 rounded-[2rem] flex items-center gap-4 hover:border-blue-600 border border-gray-100 dark:border-gray-700 transition-all shadow-sm active:scale-95">
|
||||
<div class="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 w-12 h-12 rounded-2xl flex items-center justify-center group-hover:rotate-12 transition-transform shrink-0">
|
||||
<i class="fa-solid fa-truck-pickup text-xl"></i>
|
||||
</div>
|
||||
<div class="flex flex-col text-left">
|
||||
<span class="font-black uppercase text-xs tracking-widest dark:text-white leading-none">Vozni park</span>
|
||||
<span class="text-[10px] text-gray-400 mt-1 italic font-bold uppercase tracking-tighter">Status flote</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/kupci/svi" class="group bg-white dark:bg-gray-800 text-gray-900 dark:text-white p-6 rounded-[2rem] flex items-center gap-4 hover:border-indigo-600 border border-gray-100 dark:border-gray-700 transition-all shadow-sm active:scale-95">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 w-12 h-12 rounded-2xl flex items-center justify-center group-hover:rotate-12 transition-transform shrink-0">
|
||||
<i class="fa-solid fa-address-book text-xl"></i>
|
||||
</div>
|
||||
<div class="flex flex-col text-left">
|
||||
<span class="font-black uppercase text-xs tracking-widest dark:text-white leading-none">Klijenti</span>
|
||||
<span class="text-[10px] text-gray-400 mt-1 italic font-bold uppercase tracking-tighter">Baza partnera</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="mt-10 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button variant="primary" class="w-full justify-center gap-2 !rounded-2xl py-4 uppercase font-black italic tracking-widest">
|
||||
<i class="fa-solid fa-print"></i> Ispis naloga
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tip === 'radni-nalog' && (
|
||||
<>
|
||||
<button class="w-full py-4 bg-blue-600 text-white rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20">
|
||||
<i class="fa-solid fa-check-double mr-2"></i> Završi nalog
|
||||
</button>
|
||||
<button class="w-full py-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-gray-200 transition-all">
|
||||
<i class="fa-solid fa-print mr-2"></i> Ispis (PDF)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tip === 'stroj' && (
|
||||
<>
|
||||
<button class="w-full py-4 bg-green-600 text-white rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-green-700 transition-all shadow-lg shadow-green-500/20">
|
||||
<i class="fa-solid fa-plus mr-2"></i> Novi Radni Nalog
|
||||
</button>
|
||||
<button class="w-full py-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-2xl font-black uppercase text-[11px] tracking-widest">
|
||||
<i class="fa-solid fa-file-contract mr-2"></i> Atesti i Dokumenti
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tip === 'vlasnik' && (
|
||||
<>
|
||||
<!-- AKCIJE ZA KUPCA -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[2.5rem] p-6 shadow-xl border border-gray-100 dark:border-gray-700 mb-6">
|
||||
<label class="text-[9px] font-black uppercase text-indigo-600 block mb-4 tracking-[0.2em] italic">Upravljanje klijentom</label>
|
||||
<div class="grid gap-3">
|
||||
<Button class="w-full py-4 bg-indigo-600 text-white rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-500/20">
|
||||
<i class="fa-solid fa-pen-to-square mr-2"></i> Uredi podatke
|
||||
</Button>
|
||||
<a href={`tel:${podaci.telefon}`} class="w-full py-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-gray-200 transition-all text-center no-underline">
|
||||
<i class="fa-solid fa-phone mr-2"></i> Nazovi klijenta
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DETALJI TVRTKE (Metadata preseljena s glavnog ekrana) -->
|
||||
<div class="bg-indigo-50/50 dark:bg-indigo-900/10 rounded-[2.5rem] p-6 border border-dashed border-indigo-200 dark:border-indigo-800">
|
||||
<label class="text-[9px] font-black uppercase text-indigo-400 block mb-4 tracking-[0.2em] italic">Podaci o tvrtki</label>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col border-b border-indigo-100/50 dark:border-indigo-900/30 pb-2">
|
||||
<span class="text-[9px] font-bold text-gray-400 uppercase">OIB</span>
|
||||
<span class="text-sm font-black dark:text-gray-200 tracking-tighter">{podaci.oib}</span>
|
||||
</div>
|
||||
<div class="flex flex-col border-b border-indigo-100/50 dark:border-indigo-900/30 pb-2">
|
||||
<span class="text-[9px] font-bold text-gray-400 uppercase">Adresa</span>
|
||||
<span class="text-sm font-black dark:text-gray-200 leading-tight">{podaci.adresa}, {podaci.grad}</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[9px] font-bold text-gray-400 uppercase">Email</span>
|
||||
<span class="text-sm font-black text-indigo-600 truncate">{podaci.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<!-- SEKCIJA 2: KONTEKSTUALNI DETALJI (Ovdje selimo manje bitne stvari) -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900/40 rounded-[2.5rem] p-6 border border-dashed border-gray-200 dark:border-gray-700">
|
||||
<label class="text-[9px] font-black uppercase text-gray-400 block mb-4 tracking-[0.2em] italic">Informacije sustava</label>
|
||||
|
||||
<div class="space-y-4">
|
||||
{tip === 'radni-nalog' && (
|
||||
<>
|
||||
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase">Prioritet</span>
|
||||
<span class="text-xs font-black text-red-500 uppercase italic">Visok</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase">Izradio</span>
|
||||
<span class="text-xs font-black dark:text-gray-200">Admin Sustava</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase">Ažurirano</span>
|
||||
<span class="text-xs font-black dark:text-gray-200">{formatDate(podaci.datum_azuriranja)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tip === 'stroj' && (
|
||||
<>
|
||||
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase">Zadnji Atest</span>
|
||||
<span class="text-xs font-black text-green-500 italic">{formatDate(podaci.datum_zadnjeg_atesta)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase">Godina Proizv.</span>
|
||||
<span class="text-xs font-black dark:text-gray-200">{podaci.godina_proizvodnje}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase">Sustav ID</span>
|
||||
<span class="text-xs font-black dark:text-gray-200">#{podaci.id}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
30
001.FRONTEND/src/components/Button.astro
Normal file
30
001.FRONTEND/src/components/Button.astro
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
// src/components/Button.astro
|
||||
interface Props {
|
||||
type?: "button" | "submit";
|
||||
variant?: "primary" | "danger" | "outline";
|
||||
id?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { type = "button", variant = "primary", id, class: className } = Astro.props;
|
||||
|
||||
const variants = {
|
||||
primary: "bg-blue-600 hover:bg-blue-700 text-white shadow-blue-500/20",
|
||||
danger: "bg-red-600 hover:bg-red-700 text-white shadow-red-500/20",
|
||||
outline: "bg-transparent border-2 border-gray-200 text-gray-500 hover:border-blue-600 hover:text-blue-600 shadow-none"
|
||||
};
|
||||
---
|
||||
|
||||
<button
|
||||
type={type}
|
||||
id={id}
|
||||
class:list={[
|
||||
"inline-flex items-center justify-center gap-4 px-10 py-6 rounded-[2rem] font-black uppercase text-xs tracking-[0.4em] transition-all active:scale-[0.98] shadow-2xl",
|
||||
variants[variant],
|
||||
className
|
||||
]}
|
||||
>
|
||||
<slot name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
77
001.FRONTEND/src/components/Gallery.astro
Normal file
77
001.FRONTEND/src/components/Gallery.astro
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
// src/components/Gallery.astro
|
||||
import { Image } from 'astro:assets';
|
||||
import 'photoswipe/style.css';
|
||||
|
||||
interface Props {
|
||||
// Prilagođeno tvom serializeru: 'url' je zapravo 'slika' u JSON-u
|
||||
images: {
|
||||
slika: string;
|
||||
opis?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}[];
|
||||
galleryId: string;
|
||||
}
|
||||
|
||||
const { images = [], galleryId } = Astro.props;
|
||||
|
||||
// Filtriramo važeće slike (pazimo na naziv polja 'slika' iz serializera)
|
||||
const validImages = images.filter(img => img && img.slika);
|
||||
---
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id={galleryId}>
|
||||
{validImages.map((img) => (
|
||||
<a
|
||||
href={img.slika}
|
||||
data-pswp-width={img.width || 1600}
|
||||
data-pswp-height={img.height || 1200}
|
||||
target="_blank"
|
||||
class="group relative block aspect-square overflow-hidden rounded-[2rem] bg-gray-100 dark:bg-gray-800 border border-gray-100 dark:border-gray-700 shadow-sm"
|
||||
>
|
||||
<Image
|
||||
src={img.slika}
|
||||
alt={img.opis || "Detalj kvara"}
|
||||
width={400}
|
||||
height={400}
|
||||
class="object-cover h-full w-full transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
|
||||
<!-- Overlay s opisom ako postoji -->
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center text-white p-4 text-center">
|
||||
<i class="fa-solid fa-expand text-xl mb-2"></i>
|
||||
{img.opis && <span class="text-[10px] font-black uppercase tracking-widest">{img.opis}</span>}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{validImages.length === 0 && (
|
||||
<div class="p-12 border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-[2rem] text-center">
|
||||
<i class="fa-solid fa-images text-gray-200 dark:text-gray-700 text-3xl mb-3"></i>
|
||||
<p class="text-gray-400 text-xs font-medium italic">Nema priložene foto dokumentacije.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<script>
|
||||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||||
|
||||
const initPhotoSwipe = () => {
|
||||
// Hvata sve galerije, neovisno o prefiksu, važno je da imaju ID
|
||||
const lightboxes = document.querySelectorAll('.grid[id]');
|
||||
lightboxes.forEach((el) => {
|
||||
const lightbox = new PhotoSwipeLightbox({
|
||||
gallery: `#${el.id}`,
|
||||
children: 'a',
|
||||
pswpModule: () => import('photoswipe'),
|
||||
// Dodajemo opciju za kretanje kotačićem miša
|
||||
wheelToZoom: true
|
||||
});
|
||||
lightbox.init();
|
||||
});
|
||||
};
|
||||
|
||||
// Inicijalizacija pri učitavanju i pri Astro navigaciji (View Transitions)
|
||||
initPhotoSwipe();
|
||||
document.addEventListener('astro:after-swap', initPhotoSwipe);
|
||||
</script>
|
||||
114
001.FRONTEND/src/components/GenericKarticaItem.astro
Normal file
114
001.FRONTEND/src/components/GenericKarticaItem.astro
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
// src/components/GenericKarticaItem.astro
|
||||
import { getStatusColorClass } from "../utils/ui";
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
status: string;
|
||||
statusBojaClass?: string;
|
||||
ikona: string;
|
||||
naslov: string;
|
||||
subNaslov?: string;
|
||||
metaTekst: string;
|
||||
statusPrikaz: string;
|
||||
bojaTeme?: "blue" | "indigo" | "emerald" | "red"; // Podrška za teme
|
||||
}
|
||||
|
||||
const {
|
||||
href,
|
||||
status,
|
||||
statusBojaClass,
|
||||
ikona,
|
||||
naslov,
|
||||
subNaslov,
|
||||
metaTekst,
|
||||
statusPrikaz,
|
||||
bojaTeme = "blue"
|
||||
} = Astro.props;
|
||||
|
||||
// Mapiranje tema na Tailwind klase
|
||||
const themes = {
|
||||
blue: {
|
||||
hover: "hover:bg-blue-50/30 dark:hover:bg-blue-900/10",
|
||||
text: "group-hover:text-blue-600 dark:group-hover:text-blue-400",
|
||||
iconBg: "group-hover:bg-blue-600",
|
||||
arrow: "group-hover:text-blue-600"
|
||||
},
|
||||
indigo: {
|
||||
hover: "hover:bg-indigo-50/30 dark:hover:bg-indigo-900/10",
|
||||
text: "group-hover:text-indigo-600 dark:group-hover:text-indigo-400",
|
||||
iconBg: "group-hover:bg-indigo-600",
|
||||
arrow: "group-hover:text-indigo-600"
|
||||
},
|
||||
emerald: {
|
||||
hover: "hover:bg-emerald-50/30 dark:hover:bg-emerald-900/10",
|
||||
text: "group-hover:text-emerald-600 dark:group-hover:text-emerald-400",
|
||||
iconBg: "group-hover:bg-emerald-600",
|
||||
arrow: "group-hover:text-emerald-400"
|
||||
}
|
||||
};
|
||||
|
||||
const activeTheme = themes[bojaTeme] || themes.blue;
|
||||
const resolvedStatusColor = statusBojaClass || getStatusColorClass(status);
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class:list={[
|
||||
"nalog-item flex items-center justify-between p-8 border-b border-gray-50 dark:border-gray-700 last:border-0 group transition-all duration-300",
|
||||
activeTheme.hover
|
||||
]}
|
||||
data-status={status}
|
||||
>
|
||||
<div class="flex items-center gap-6 text-left">
|
||||
<!-- Ikona s dinamičkom bojom na hoveru -->
|
||||
<div class:list={[
|
||||
"w-14 h-14 rounded-2xl flex items-center justify-center text-xl shadow-inner transition-all duration-300 bg-gray-50 dark:bg-gray-900 text-gray-400 group-hover:text-white",
|
||||
activeTheme.iconBg
|
||||
]}>
|
||||
<i class:list={["fa-solid", ikona, status === 'u_radu' ? 'animate-pulse' : '']}></i>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class:list={[
|
||||
"font-black text-gray-900 dark:text-white text-xl uppercase tracking-tighter italic transition-colors leading-none",
|
||||
activeTheme.text
|
||||
]}>
|
||||
{naslov}
|
||||
</span>
|
||||
{subNaslov && (
|
||||
<span class="text-[10px] font-black bg-gray-900 text-white px-2 py-0.5 rounded border border-gray-700 uppercase tracking-[0.2em]">
|
||||
{subNaslov}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span class="text-gray-400 text-[10px] font-black uppercase tracking-widest mt-2 italic leading-none">
|
||||
{metaTekst}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Status indikator desno -->
|
||||
<div class="hidden lg:flex flex-col items-end text-right">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class:list={["w-2 h-2 rounded-full", resolvedStatusColor]}></div>
|
||||
<span class="text-[8px] font-black uppercase text-gray-400 tracking-[0.2em]">Status</span>
|
||||
</div>
|
||||
<span class:list={[
|
||||
"text-xs font-black uppercase italic transition-colors",
|
||||
status === 'zavrseno' || status === 'naplaceno' || status === 'aktivan' ? 'text-emerald-500' : activeTheme.text
|
||||
]}>
|
||||
{statusPrikaz}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Strelica -->
|
||||
<i class:list={[
|
||||
"fa-solid fa-arrow-right-long text-gray-200 group-hover:translate-x-2 transition-all duration-300",
|
||||
activeTheme.arrow
|
||||
]}></i>
|
||||
</div>
|
||||
</a>
|
||||
152
001.FRONTEND/src/components/Kalendar/Kalendar.astro
Normal file
152
001.FRONTEND/src/components/Kalendar/Kalendar.astro
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
// src/components/Kalendar.astro
|
||||
import { fetchCalendarEvents } from "../../lib/api";
|
||||
import { getStatusColorClass } from "../../utils/ui";
|
||||
|
||||
// 1. Dohvat podataka s Django API-ja kroz tvoj centralizirani lib
|
||||
const apiEvents = await fetchCalendarEvents();
|
||||
|
||||
// 2. Grupiranje događaja po datumu (YYYY-MM-DD)
|
||||
const eventsByDate = apiEvents.reduce((acc, event) => {
|
||||
const dateKey = new Date(event.start).toISOString().split('T')[0];
|
||||
if (!acc[dateKey]) acc[dateKey] = [];
|
||||
acc[dateKey].push(event);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Postavke za trenutni prikaz (Svibanj 2026)
|
||||
const daysInMonth = 31;
|
||||
const firstDayOffset = 5; // Petak
|
||||
const currentYearMonth = "2026-05";
|
||||
---
|
||||
|
||||
<div class="w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-2xl rounded-[3rem] border border-gray-100 dark:border-gray-700 overflow-hidden font-sans transition-all">
|
||||
|
||||
<div class="flex justify-between items-center bg-gray-50/50 dark:bg-gray-900/50 px-8 py-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<button class="text-gray-400 hover:text-blue-600 transition-colors text-2xl font-black">‹</button>
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-black uppercase tracking-tighter italic leading-none">Svibanj 2026</h2>
|
||||
<span class="text-[10px] text-blue-600 font-bold uppercase tracking-[0.2em] mt-1 italic">Raspored servisa</span>
|
||||
</div>
|
||||
<button class="text-gray-400 hover:text-blue-600 transition-colors text-2xl font-black">›</button>
|
||||
</div>
|
||||
|
||||
<div id="calendar-body">
|
||||
<div class="grid grid-cols-7 text-center text-[10px] text-gray-400 dark:text-gray-500 uppercase font-black tracking-[0.2em] py-4 border-b border-gray-50 dark:border-gray-700/50">
|
||||
<div>Ned</div><div>Pon</div><div>Uto</div><div>Sri</div><div>Čet</div><div>Pet</div><div>Sub</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 text-center" id="days-grid">
|
||||
{Array.from({ length: firstDayOffset }).map(() => (
|
||||
<div class="py-6 border-b border-r border-gray-50 dark:border-gray-700/10 opacity-0"></div>
|
||||
))}
|
||||
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const dateKey = `${currentYearMonth}-${day.toString().padStart(2, '0')}`;
|
||||
const dayEvents = eventsByDate[dateKey] || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
class="day-cell py-6 relative cursor-pointer hover:bg-blue-50/50 dark:hover:bg-gray-700/30 transition-all border-b border-r border-gray-50 dark:border-gray-700/30 group"
|
||||
data-day={day}
|
||||
data-events={JSON.stringify(dayEvents)}
|
||||
>
|
||||
<span class={`text-3xl font-black italic tracking-tighter transition-colors ${dayEvents.length > 0 ? 'text-gray-900 dark:text-white' : 'text-gray-300 dark:text-gray-600'} group-hover:text-blue-600`}>
|
||||
{day.toString().padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
<div class="flex justify-center gap-1 mt-1 h-1.5">
|
||||
{dayEvents.map(event => (
|
||||
<div class={`w-1.5 h-1.5 rounded-full ${getStatusColorClass(event.tip)}`}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div id="event-panel" class="hidden col-span-7 bg-blue-600 text-white p-8 relative overflow-visible shadow-2xl">
|
||||
<div class="absolute -top-2 left-0 w-full h-2">
|
||||
<div id="panel-arrow" class="absolute w-0 h-0 border-l-[12px] border-l-transparent border-r-[12px] border-r-transparent border-b-[12px] border-b-blue-600 transition-all duration-300"></div>
|
||||
</div>
|
||||
<div id="event-list" class="flex flex-col gap-6 w-full">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatStatusLocal(status) {
|
||||
const map = { 'u_radu': 'U radu', 'planirano': 'Planirano', 'zavrseno': 'Završeno', 'servis': 'Na servisu' };
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
function initKalendar() {
|
||||
const grid = document.getElementById('days-grid');
|
||||
const panel = document.getElementById('event-panel');
|
||||
const arrow = document.getElementById('panel-arrow');
|
||||
const eventList = document.getElementById('event-list');
|
||||
const dayCells = document.querySelectorAll('.day-cell');
|
||||
|
||||
if (!grid || !panel) return;
|
||||
|
||||
dayCells.forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
const eventsData = JSON.parse(cell.getAttribute('data-events') || '[]');
|
||||
|
||||
if (eventsData.length > 0) {
|
||||
const allItems = Array.from(grid.children);
|
||||
const index = allItems.indexOf(cell);
|
||||
|
||||
// 1. Pozicioniranje strelice (Matematički precizno)
|
||||
const colIndex = index % 7;
|
||||
const percentage = (colIndex * (100 / 7)) + (100 / 7 / 2);
|
||||
arrow.style.left = `calc(${percentage}% - 12px)`;
|
||||
|
||||
// 2. Injekcija panela na kraj tjedna
|
||||
const rowEndIndex = Math.floor(index / 7) * 7 + 6;
|
||||
const targetCell = allItems[Math.min(rowEndIndex, allItems.length - 1)];
|
||||
|
||||
// 3. Generiranje liste (text-left poravnanje)
|
||||
eventList.innerHTML = eventsData.map(ev => `
|
||||
<div class="flex items-start gap-5 border-b border-white/20 pb-6 last:border-0 last:pb-0 group/item">
|
||||
<a href="/operativa/radni-nalozi/${ev.radni_nalog}" class="bg-white/10 hover:bg-white hover:text-blue-600 text-white p-4 rounded-2xl transition-all shadow-lg shrink-0 group-hover/item:scale-105 active:scale-95">
|
||||
<i class="fa-solid ${ev.je_radni_nalog ? 'fa-file-signature' : 'fa-wrench'} text-2xl"></i>
|
||||
</a>
|
||||
<div class="flex flex-col flex-1 text-left">
|
||||
<div class="flex flex-wrap justify-between items-start gap-2">
|
||||
<a href="/operativa/radni-nalozi/${ev.radni_nalog}" class="hover:underline">
|
||||
<span class="font-black uppercase text-2xl tracking-tighter italic leading-none">${ev.title}</span>
|
||||
</a>
|
||||
<span class="text-[10px] font-black bg-white/20 px-3 py-1 rounded-full uppercase tracking-widest border border-white/10">
|
||||
${formatStatusLocal(ev.tip)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm opacity-90 font-medium mt-2 leading-relaxed max-w-2xl text-left w-full">
|
||||
${ev.opis}
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest bg-black/30 px-3 py-1.5 rounded-lg flex items-center gap-2">
|
||||
<i class="fa-solid fa-user-gear opacity-50"></i> ${ev.izvrsitelj_ime}
|
||||
</span>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest bg-black/30 px-3 py-1.5 rounded-lg flex items-center gap-2">
|
||||
<i class="fa-solid fa-clock opacity-50"></i> ${new Date(ev.start).toLocaleTimeString('hr-HR', {hour: '2-digit', minute:'2-digit'})} h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
targetCell.after(panel);
|
||||
panel.classList.remove('hidden');
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initKalendar();
|
||||
document.addEventListener('astro:after-swap', initKalendar);
|
||||
</script>
|
||||
59
001.FRONTEND/src/components/ListaStrojeva.astro
Normal file
59
001.FRONTEND/src/components/ListaStrojeva.astro
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
// src/components/ListaStrojeva.astro
|
||||
import NaslovList from "./NaslovList.astro";
|
||||
import GenericKarticaItem from "./GenericKarticaItem.astro";
|
||||
import { getStatusColorClass, formatStatus } from "../utils/ui";
|
||||
|
||||
interface Props {
|
||||
strojevi: any[];
|
||||
naslov?: string;
|
||||
prikaziNaslov?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
strojevi = [],
|
||||
naslov = "Tehničke jedinice",
|
||||
prikaziNaslov = true
|
||||
} = Astro.props;
|
||||
|
||||
// Statistika za NaslovList (npr. koliko ih je aktivno ili na servisu ako imaš te podatke)
|
||||
const ukupnoStrojeva = strojevi.length;
|
||||
// Pretpostavljamo da strojevi imaju status, ako nemaju, možemo staviti fiksne labele
|
||||
const uRadu = strojevi.filter(s => s.status === 'u_radu').length;
|
||||
---
|
||||
|
||||
<div class="space-y-6">
|
||||
|
||||
{prikaziNaslov && (
|
||||
<NaslovList
|
||||
naslov={naslov}
|
||||
ukupno={ukupnoStrojeva}
|
||||
label1="Ukupno"
|
||||
count1={ukupnoStrojeva}
|
||||
label2="Aktivno"
|
||||
count2={uRadu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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="strojevi-list">
|
||||
{strojevi.length > 0 ? strojevi.map((s) => (
|
||||
<GenericKarticaItem
|
||||
href={`/fleet/strojevi/${s.id}`}
|
||||
status={s.status || 'aktivan'}
|
||||
statusBojaClass={getStatusColorClass(s.status || 'aktivan')}
|
||||
ikona="fa-screwdriver-wrench"
|
||||
naslov={s.naziv}
|
||||
subNaslov={s.serijski_broj}
|
||||
metaTekst={`${s.vlasnik_naziv} | Radni sati: ${parseFloat(s.radni_sati || 0).toLocaleString('hr-HR')} h`}
|
||||
statusPrikaz={formatStatus(s.status || 'aktivan')}
|
||||
/>
|
||||
)) : (
|
||||
<div class="p-20 text-center italic text-gray-400 text-[10px] uppercase tracking-widest leading-none">
|
||||
<i class="fa-solid fa-box-open text-3xl text-gray-100 dark:text-gray-700 mb-4 block"></i>
|
||||
Nema dostupnih tehničkih jedinica
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
103
001.FRONTEND/src/components/LoginForm.astro
Normal file
103
001.FRONTEND/src/components/LoginForm.astro
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
// src/components/LoginForm.astro
|
||||
import Button from "./Button.astro";
|
||||
---
|
||||
|
||||
<div class="max-w-md mx-auto w-full p-10 bg-white dark:bg-gray-900 rounded-[3rem] border border-gray-100 dark:border-gray-800 shadow-2xl shadow-blue-500/10">
|
||||
<div class="mb-10 text-center sm:text-left">
|
||||
<div class="inline-flex w-14 h-14 bg-blue-600 rounded-2xl items-center justify-center shadow-lg shadow-blue-500/20 mb-6 rotate-3 sm:-ml-2">
|
||||
<i class="fa-solid fa-lock text-white text-xl"></i>
|
||||
</div>
|
||||
<h2 class="text-4xl font-black text-gray-900 dark:text-white uppercase tracking-tighter italic leading-none">Prijava</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] mt-3 opacity-70 italic">
|
||||
ServisLog <span class="text-blue-600">/</span> Terminal
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="space-y-6">
|
||||
<div>
|
||||
<label for="email" class="block text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 mb-2 ml-4 italic">Korisnički E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
class="w-full px-6 py-4 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-blue-600 rounded-2xl outline-none transition-all text-gray-900 dark:text-white font-bold italic shadow-inner"
|
||||
placeholder="serviser@tvrtka.hr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 mb-2 ml-4 italic">Lozinka</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
class="w-full px-6 py-4 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-blue-600 rounded-2xl outline-none transition-all text-gray-900 dark:text-white font-bold tracking-[0.3em] shadow-inner"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
id="login-btn"
|
||||
variant="primary"
|
||||
class="w-full !py-5 italic"
|
||||
>
|
||||
<i class="fa-solid fa-bolt" slot="icon"></i>
|
||||
Pokreni Sustav
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { login } from "../lib/api";
|
||||
|
||||
const loginForm = document.querySelector('#login-form') as HTMLFormElement;
|
||||
const btn = document.querySelector('#login-btn') as HTMLButtonElement;
|
||||
|
||||
loginForm?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
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...';
|
||||
|
||||
const formData = new FormData(loginForm);
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
try {
|
||||
const result = await login(email, password);
|
||||
|
||||
if (result.success) {
|
||||
sessionStorage.setItem('pending_toast', JSON.stringify({
|
||||
type: 'success',
|
||||
message: 'Pristup odobren. Dobrodošli u sustav.'
|
||||
}));
|
||||
window.location.href = '/';
|
||||
} 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
|
||||
window.showToast?.("Sustav nedostupan. Provjerite vezu.", "error");
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalContent;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
59
001.FRONTEND/src/components/NaslovList.astro
Normal file
59
001.FRONTEND/src/components/NaslovList.astro
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
// src/components/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;
|
||||
}
|
||||
|
||||
const { naslov, ukupno, label1, count1, filter1, label2, count2, filter2, label3, count3, filter3 } = 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="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}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
107
001.FRONTEND/src/components/Navbar.astro
Normal file
107
001.FRONTEND/src/components/Navbar.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
// src/components/Navbar.astro
|
||||
import site from "../data/site.json";
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<nav class="sticky top-0 z-[100] w-full bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-100 dark:border-gray-800">
|
||||
<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">
|
||||
<i class="fa-solid fa-screwdriver-wrench text-white text-lg"></i>
|
||||
</div>
|
||||
<span class="text-xl font-black tracking-tighter text-gray-900 dark:text-white uppercase">
|
||||
{site.title.split(' ')[0]}<span class="text-blue-600">{site.title.split(' ')[1] || ''}</span>
|
||||
</span>
|
||||
</a>
|
||||
</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
|
||||
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]
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
function setupNavbar() {
|
||||
const btn = document.getElementById('mobile-menu-button');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
|
||||
if (btn && menu) {
|
||||
// Re-inicijalizacija gumba za Astro View Transitions
|
||||
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
|
||||
btn.parentNode?.replaceChild(newBtn, btn);
|
||||
|
||||
newBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
|
||||
// Zatvori nakon odabira linka
|
||||
menu.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => menu.classList.add('hidden'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
setupNavbar();
|
||||
document.addEventListener('astro:after-swap', setupNavbar);
|
||||
|
||||
</script>
|
||||
94
001.FRONTEND/src/components/RadniNalogLista.astro
Normal file
94
001.FRONTEND/src/components/RadniNalogLista.astro
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
// 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;
|
||||
naslov?: string;
|
||||
prikaziNaslov?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
limit = 0,
|
||||
naslov = "Zadnje aktivnosti",
|
||||
prikaziNaslov = true,
|
||||
} = Astro.props;
|
||||
|
||||
// 1. Dohvat filtera iz URL-a (npr. ?status=u_radu)
|
||||
const statusFilter = Astro.url.searchParams.get('status');
|
||||
|
||||
// 2. Dohvat podataka s API-ja
|
||||
const { nalozi = [] } = await fetchDashboardData();
|
||||
|
||||
// 3. Logika filtriranja (Serverska strana)
|
||||
let filtriraniNalozi = nalozi;
|
||||
|
||||
if (statusFilter) {
|
||||
filtriraniNalozi = nalozi.filter(n => n.status === statusFilter);
|
||||
}
|
||||
|
||||
// 4. Izračun brojeva za NaslovList (uvijek se računa iz originalne liste nalozi)
|
||||
const planiranoCount = nalozi.filter(n => n.status === 'planirano').length;
|
||||
const uRaduCount = nalozi.filter(n => n.status === '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">
|
||||
{prikaziNaslov && (
|
||||
<NaslovList
|
||||
naslov={prikazaniNaslov}
|
||||
ukupno={filtriraniNalozi.length}
|
||||
label1="Planirano"
|
||||
count1={planiranoCount}
|
||||
filter1="planirano"
|
||||
label2="U radu"
|
||||
count2={uRaduCount}
|
||||
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}
|
||||
statusBojaClass={getStatusColorClass(n.status)}
|
||||
ikona={n.status === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice'}
|
||||
naslov={`#${n.broj_naloga}`}
|
||||
subNaslov={formatStatus(n.status)}
|
||||
metaTekst={`${n.klijent_naziv} | ${n.vozilo_naziv} | ${n.stroj_naziv}`}
|
||||
statusPrikaz={formatStatus(n.status)}
|
||||
bojaTeme={n.boja_teme || 'blue'}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div class="p-20 text-center uppercase font-black opacity-20 italic">
|
||||
Nema zapisa za odabrani status
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Ponovno inicijaliziramo klijentsku skriptu za klikove na kartice filtera
|
||||
import { initFilters } from "../scripts/filters.js";
|
||||
|
||||
// Prvo pokretanje
|
||||
initFilters();
|
||||
|
||||
// Podrška za Astro View Transitions (ako ih koristiš)
|
||||
document.addEventListener('astro:after-swap', initFilters);
|
||||
</script>
|
||||
48
001.FRONTEND/src/components/StatsGrid.astro
Normal file
48
001.FRONTEND/src/components/StatsGrid.astro
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
// src/components/StatsGrid.astro
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: string;
|
||||
variant?: 'blue' | 'yellow' | 'emerald' | 'gray';
|
||||
animate?: boolean;
|
||||
filterValue?: string; // NOVO: npr. 'u_radu', 'planirano'...
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
variant = 'blue',
|
||||
animate = false,
|
||||
filterValue = "",
|
||||
class: className = ""
|
||||
} = Astro.props;
|
||||
|
||||
const themes = {
|
||||
blue: { container: "border-gray-100 dark:border-gray-700", iconBg: "bg-blue-50 dark:bg-blue-900/30", iconColor: "text-blue-600 dark:text-blue-400", labelColor: "text-gray-400" },
|
||||
yellow: { container: "border-yellow-500/20 border-2", iconBg: "bg-yellow-500", iconColor: "text-white", labelColor: "text-yellow-600" },
|
||||
emerald: { container: "border-gray-100 dark:border-gray-700", iconBg: "bg-emerald-50 dark:bg-emerald-900/30", iconColor: "text-emerald-600 dark:text-emerald-400", labelColor: "text-gray-400" },
|
||||
gray: { container: "border-gray-100 dark:border-gray-700", iconBg: "bg-gray-100 dark:bg-gray-800", iconColor: "text-gray-500", labelColor: "text-gray-400" }
|
||||
};
|
||||
|
||||
const theme = themes[variant];
|
||||
---
|
||||
|
||||
<div
|
||||
class={`stat-card bg-white dark:bg-gray-800 p-3 sm:p-6 rounded-2xl sm:rounded-[2.5rem] shadow-sm border flex flex-row items-center justify-center gap-2 sm:gap-5 cursor-pointer hover:scale-[1.02] active:scale-95 transition-all ${theme.container} ${className}`}
|
||||
>
|
||||
<div class={`p-1.5 sm:p-4 rounded-lg sm:rounded-2xl w-7 h-7 sm:w-16 sm:h-16 flex items-center justify-center flex-none ${theme.iconBg} ${theme.iconColor} ${animate ? 'animate-pulse' : ''}`}>
|
||||
<i class={`fa-solid ${icon} text-xs sm:text-2xl`}></i>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center">
|
||||
<h3 class="text-sm sm:text-4xl font-black text-gray-900 dark:text-white leading-none tracking-tighter">
|
||||
{value}
|
||||
</h3>
|
||||
<p class={`text-[6px] sm:text-xs font-black uppercase tracking-widest mt-0.5 sm:mt-1 ${theme.labelColor}`}>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
73
001.FRONTEND/src/components/Toast.astro
Normal file
73
001.FRONTEND/src/components/Toast.astro
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
// src/components/Toast.astro
|
||||
---
|
||||
<div id="global-toast" class="fixed bottom-0 left-0 w-full z-[999] hidden transition-all duration-500 transform translate-y-full">
|
||||
<div id="toast-bg" class="px-6 py-6 flex items-center shadow-[0_-10px_40px_rgba(0,0,0,0.2)] border-t">
|
||||
<div class="max-w-7xl mx-auto w-full flex items-center gap-6">
|
||||
<!-- Icon Container -->
|
||||
<div id="toast-icon-wrapper" class="w-14 h-14 bg-white/20 rounded-[1.2rem] flex items-center justify-center shadow-inner">
|
||||
<i id="toast-icon" class="fa-solid text-2xl"></i>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div class="flex flex-col">
|
||||
<span id="toast-label" class="text-[10px] font-black uppercase tracking-[0.3em] opacity-70 leading-none italic"></span>
|
||||
<p id="toast-message" class="text-lg font-black uppercase tracking-tight mt-1 italic leading-none text-white"></p>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button id="close-toast" class="ml-auto w-10 h-10 hover:bg-white/10 rounded-xl transition-colors text-white">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Logika koja se može pozvati s bilo koje stranice
|
||||
window.showToast = (message, type = 'success') => {
|
||||
const toast = document.getElementById('global-toast');
|
||||
const bg = document.getElementById('toast-bg');
|
||||
const icon = document.getElementById('toast-icon');
|
||||
const label = document.getElementById('toast-label');
|
||||
const msg = document.getElementById('toast-message');
|
||||
|
||||
if (!toast || !bg || !icon || !label || !msg) return;
|
||||
|
||||
// Konfiguracija tema
|
||||
const themes = {
|
||||
success: { bg: 'bg-emerald-600', border: 'border-emerald-500', icon: 'fa-check-double', label: 'Sustav Potvrđuje' },
|
||||
error: { bg: 'bg-red-600', border: 'border-red-500', icon: 'fa-circle-exclamation', label: 'Sustav Odbija' },
|
||||
warning: { bg: 'bg-orange-500', border: 'border-orange-400', icon: 'fa-triangle-exclamation', label: 'Sustav Upozorava' }
|
||||
};
|
||||
|
||||
const theme = themes[type] || themes.success;
|
||||
|
||||
// Primjena stilova
|
||||
bg.className = `${theme.bg} ${theme.border} text-white px-6 py-6 flex items-center shadow-2xl border-t`;
|
||||
icon.className = `fa-solid ${theme.icon} text-2xl text-white`;
|
||||
label.innerText = theme.label;
|
||||
msg.innerText = message;
|
||||
|
||||
// Animacija ulaska
|
||||
toast.classList.remove('hidden', 'translate-y-full');
|
||||
|
||||
// Auto-hide nakon 5s
|
||||
setTimeout(() => {
|
||||
toast.classList.add('translate-y-full');
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// Zatvaranje na gumb
|
||||
document.getElementById('close-toast')?.addEventListener('click', () => {
|
||||
document.getElementById('global-toast')?.classList.add('translate-y-full');
|
||||
});
|
||||
|
||||
// Provjera SessionStorage-a (za poruke nakon redirecta)
|
||||
const pending = sessionStorage.getItem('pending_toast');
|
||||
if (pending) {
|
||||
const { type, message } = JSON.parse(pending);
|
||||
window.showToast(message, type);
|
||||
sessionStorage.removeItem('pending_toast');
|
||||
}
|
||||
</script>
|
||||
36
001.FRONTEND/src/components/WelcomeHeader.astro
Normal file
36
001.FRONTEND/src/components/WelcomeHeader.astro
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
// src/components/WelcomeHeader.astro
|
||||
import site from "../data/site.json";
|
||||
|
||||
const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
|
||||
const currentPageData = site.navigation.find(item => item.url === currentPath);
|
||||
|
||||
console.log("Current Path:", currentPath);
|
||||
|
||||
const {
|
||||
welcomeHeaderTextH1 = "Dobrodošli",
|
||||
welcomeHeaderTextH1dodatno = "",
|
||||
welcomeHeaderPodnaslov = "Pregled sustava",
|
||||
welcomeHeaderPovratniURL = "",
|
||||
welcomeHeaderDisplay = true
|
||||
} = currentPageData || {};
|
||||
---
|
||||
|
||||
{welcomeHeaderDisplay && (
|
||||
<header class="mb-12 px-2">
|
||||
{welcomeHeaderPovratniURL && (
|
||||
<a href={welcomeHeaderPovratniURL} class="text-blue-600 text-[10px] font-black uppercase tracking-widest no-underline flex items-center gap-2 mb-6 hover:opacity-70 transition-opacity italic">
|
||||
<i class="fa-solid fa-arrow-left text-[8px]"></i> Povratak
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div class="text-left">
|
||||
<h1 class="text-4xl sm:text-6xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none italic">
|
||||
{welcomeHeaderTextH1} <span class="text-blue-600"> {welcomeHeaderTextH1dodatno}</span>
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-4 font-medium italic text-lg leading-tight">
|
||||
{welcomeHeaderPodnaslov}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
97
001.FRONTEND/src/data/site.json
Normal file
97
001.FRONTEND/src/data/site.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"title": "ServisLog",
|
||||
"description": "Profesionalni sustav za upravljanje servisnim operacijama",
|
||||
"url": "https://servislog.hr",
|
||||
"author": "Servis tim",
|
||||
"theme": {
|
||||
"mode": "dark",
|
||||
"color": "blue"
|
||||
},
|
||||
"navigation": [
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"url": "/",
|
||||
"icon": "fa-chart-line",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Kontrolna",
|
||||
"welcomeHeaderTextH1dodatno": " ploča",
|
||||
"welcomeHeaderPodnaslov": "Pregled ključnih informacija u stvarnom vremenu",
|
||||
"welcomeHeaderPovratniURL": ""
|
||||
},
|
||||
{
|
||||
"name": "Radni nalozi",
|
||||
"url": "/operativa/radni-nalozi",
|
||||
"icon": "fa-file-signature",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Radni",
|
||||
"welcomeHeaderTextH1dodatno": " nalozi",
|
||||
"welcomeHeaderPodnaslov": "Pregled ključnih informacija u stvarnom vremenu",
|
||||
"welcomeHeaderPovratniURL": "/"
|
||||
},
|
||||
{
|
||||
"name": "Vozni Park",
|
||||
"url": "/fleet/vozila",
|
||||
"icon": "fa-truck-pickup",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Vozni",
|
||||
"welcomeHeaderTextH1dodatno": " park",
|
||||
"welcomeHeaderPodnaslov": "Sustavna evidencija i nadzor mobilnih resursa",
|
||||
"welcomeHeaderPovratniURL": "/"
|
||||
},
|
||||
{
|
||||
"name": "Registrirani strojevi",
|
||||
"url": "/fleet/strojevi",
|
||||
"icon": "fa-truck-pickup",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Vozni",
|
||||
"welcomeHeaderTextH1dodatno": " park",
|
||||
"welcomeHeaderPodnaslov": "Sustavna evidencija i nadzor mobilnih resursa",
|
||||
"welcomeHeaderPovratniURL": "/"
|
||||
},
|
||||
{
|
||||
"name": "Klijenti",
|
||||
"url": "/kupci/svi",
|
||||
"icon": "fa-address-book",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Pregled",
|
||||
"welcomeHeaderTextH1dodatno": "klijenata",
|
||||
"welcomeHeaderPodnaslov": "Upravljanje bazom korisnika i partnera",
|
||||
"welcomeHeaderPovratniURL": "/"
|
||||
},
|
||||
{
|
||||
"name": "Kalendar",
|
||||
"url": "/kalendar-dogadaja",
|
||||
"icon": "fa-calendar-days",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Kalendar",
|
||||
"welcomeHeaderTextH1dodatno": "događaja",
|
||||
"welcomeHeaderPodnaslov": "Pregled operacija u stvarnom vremenu",
|
||||
"welcomeHeaderPovratniURL": "/"
|
||||
},
|
||||
{
|
||||
"name": "Login",
|
||||
"url": "/login",
|
||||
"icon": "fa-calendar-days",
|
||||
"welcomeHeaderDisplay": false,
|
||||
"welcomeHeaderTextH1": "Prijava",
|
||||
"welcomeHeaderTextH1dodatno": " korisnika",
|
||||
"welcomeHeaderPodnaslov": "Pristup sustavu za ovlaštene korisnike",
|
||||
"welcomeHeaderPovratniURL": ""
|
||||
},
|
||||
{
|
||||
"name": "Novi Radni Nalog",
|
||||
"url": "/operativa/radni-nalozi/novi",
|
||||
"icon": "fa-calendar-days",
|
||||
"welcomeHeaderDisplay": true,
|
||||
"welcomeHeaderTextH1": "Novi",
|
||||
"welcomeHeaderTextH1dodatno": " Radni nalog",
|
||||
"welcomeHeaderPodnaslov": "Otvaranje novog servisnog ili radnog naloga u sustavu",
|
||||
"welcomeHeaderPovratniURL": "/operativa/radni-nalozi"
|
||||
}
|
||||
],
|
||||
"api_endpoints": {
|
||||
"base": "PUBLIC_API_URL",
|
||||
"auth": "/token/",
|
||||
"me": "/users/me/"
|
||||
}
|
||||
}
|
||||
67
001.FRONTEND/src/layouts/Layout.astro
Normal file
67
001.FRONTEND/src/layouts/Layout.astro
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
// 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";
|
||||
|
||||
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>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</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 />
|
||||
|
||||
<script is:inline src="https://cdn.jsdelivr.net/npm/flowbite@2.5.2/dist/flowbite.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
/* Integracija Tailwinda unutar Layouta */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Osigurava da Dark Mode radi ispravno s Flowbite-om */
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
</style>
|
||||
163
001.FRONTEND/src/pages/fleet/strojevi/[id].astro
Normal file
163
001.FRONTEND/src/pages/fleet/strojevi/[id].astro
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
// src/pages/fleet/strojevi/[id].astro
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import Button from "../../../components/Button.astro";
|
||||
import AkcijePanel from "../../../components/AkcijePanel.astro";
|
||||
|
||||
// Importiranje utilitija
|
||||
const { id } = Astro.params;
|
||||
const API_BASE = import.meta.env.PUBLIC_API_URL;
|
||||
|
||||
let stroj = null;
|
||||
|
||||
try {
|
||||
// Dohvaćamo točno određeni stroj
|
||||
const res = await fetch(`${API_BASE}/fleet/strojevi/${id}/?t=${Date.now()}`);
|
||||
if (res.ok) stroj = await res.json();
|
||||
} catch (e) {
|
||||
console.error("Greška pri dohvatu detalja stroja:", e);
|
||||
}
|
||||
|
||||
if (!stroj) return Astro.redirect("/404");
|
||||
|
||||
// Logika za Atest status (npr. ako je unutar 30 dana ili prošao)
|
||||
const atestDate = stroj.datum_zadnjeg_atesta ? new Date(stroj.datum_zadnjeg_atesta) : null;
|
||||
const isAtestExpired = atestDate && atestDate < new Date();
|
||||
---
|
||||
|
||||
<Layout title={`Stroj | ${stroj.naziv}`}>
|
||||
<div class="w-full space-y-10">
|
||||
|
||||
<header class="px-2 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||
<div class="space-y-4">
|
||||
<a href="/fleet/strojevi" class="text-blue-600 text-[10px] font-black uppercase tracking-[0.3em] hover:underline flex items-center gap-2 no-underline mb-4">
|
||||
<i class="fa-solid fa-arrow-left text-[8px]"></i> Povratak u park
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-4xl sm:text-6xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none italic">
|
||||
{stroj.naziv}
|
||||
</h1>
|
||||
<div class="hidden sm:block px-4 py-1.5 bg-blue-600 text-white rounded-full text-[10px] font-black uppercase tracking-widest italic">
|
||||
{stroj.tip_human_readable}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-500 dark:text-gray-400 font-medium italic">
|
||||
Vlasnik: <a href={`/kupci/${stroj.vlasnik}`} class="text-blue-600 underline font-bold">{stroj.vlasnik_naziv}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 px-6 py-4 bg-white dark:bg-gray-800 rounded-[2rem] border border-gray-100 dark:border-gray-700 shadow-xl">
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-[8px] font-black uppercase text-gray-400 tracking-widest">Serijski broj</span>
|
||||
<span class="text-xl font-black text-gray-900 dark:text-white tracking-tighter italic">{stroj.serijski_broj}</span>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-gray-100 dark:bg-gray-700 mx-2"></div>
|
||||
<i class="fa-solid fa-barcode text-gray-200 text-2xl"></i>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 px-2">
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden group">
|
||||
<i class="fa-solid fa-calendar absolute -right-4 -bottom-4 text-6xl opacity-5 group-hover:rotate-12 transition-transform"></i>
|
||||
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest">Godište</span>
|
||||
<span class="text-4xl font-black dark:text-white italic tracking-tighter">{stroj.godina_proizvodnje || 'N/A'}</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden group">
|
||||
<i class="fa-solid fa-gauge-high absolute -right-4 -bottom-4 text-6xl opacity-5 group-hover:rotate-12 transition-transform"></i>
|
||||
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest">Radni sati</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-black dark:text-white italic tracking-tighter">{parseFloat(stroj.radni_sati).toLocaleString('hr-HR')}</span>
|
||||
<span class="text-xs font-black text-blue-600 uppercase">H</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={`p-8 rounded-[2.5rem] border relative overflow-hidden group ${isAtestExpired ? 'bg-red-50 border-red-100 dark:bg-red-900/10 dark:border-red-900/20' : 'bg-emerald-50 border-emerald-100 dark:bg-emerald-900/10 dark:border-emerald-900/20'}`}>
|
||||
<i class="fa-solid fa-shield-check absolute -right-4 -bottom-4 text-6xl opacity-10 group-hover:rotate-12 transition-transform"></i>
|
||||
<span class={`text-[9px] font-black uppercase block mb-2 tracking-widest ${isAtestExpired ? 'text-red-600' : 'text-emerald-600'}`}>Zadnji atest</span>
|
||||
<span class={`text-2xl font-black italic tracking-tighter ${isAtestExpired ? 'text-red-600' : 'text-emerald-600'}`}>
|
||||
{stroj.datum_zadnjeg_atesta ? new Date(stroj.datum_zadnjeg_atesta).toLocaleDateString('hr-HR') : 'Nema podataka'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-900 dark:bg-black p-8 rounded-[2.5rem] text-white relative overflow-hidden group shadow-2xl">
|
||||
<i class="fa-solid fa-industry absolute -right-4 -bottom-4 text-6xl opacity-20 group-hover:rotate-12 transition-transform"></i>
|
||||
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest">Proizvođač</span>
|
||||
<span class="text-2xl font-black italic tracking-tighter block uppercase">{stroj.marka}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-10 gap-8 items-start px-2">
|
||||
|
||||
<div class="md:col-span-7 space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[3.5rem] p-10 md:p-16 border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 relative overflow-hidden">
|
||||
<div class="grid sm:grid-cols-2 gap-12 items-center">
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<label class="text-[10px] font-black uppercase tracking-[0.3em] text-blue-600 block mb-4 italic">Tehnička Specifikacija</label>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between border-b border-gray-50 dark:border-gray-700 pb-2">
|
||||
<span class="text-xs font-bold text-gray-400 uppercase italic">Model</span>
|
||||
<span class="text-sm font-black dark:text-white uppercase">{stroj.model_stroja}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-gray-50 dark:border-gray-700 pb-2">
|
||||
<span class="text-xs font-bold text-gray-400 uppercase italic">Tip jedinice</span>
|
||||
<span class="text-sm font-black dark:text-white uppercase">{stroj.tip_human_readable}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-gray-50 dark:border-gray-700 pb-2">
|
||||
<span class="text-xs font-bold text-gray-400 uppercase italic">Registracija</span>
|
||||
<span class="text-sm font-black dark:text-white uppercase">{stroj.registracija || 'NIJE REGISTRIRAN'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<Button variant="primary" class="!rounded-2xl py-4 w-full justify-center font-black uppercase italic tracking-widest">
|
||||
<i class="fa-solid fa-wrench mr-2"></i> Otvori servisni nalog
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-blue-600 rounded-[2.5rem] rotate-3 opacity-10 group-hover:rotate-0 transition-transform duration-500"></div>
|
||||
<div class="relative aspect-square bg-gray-100 dark:bg-gray-900 rounded-[2.5rem] overflow-hidden border border-gray-100 dark:border-gray-700">
|
||||
<img
|
||||
src="http://googleusercontent.com/image_collection/image_retrieval/5305205557646734227"
|
||||
alt={stroj.naziv}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 grayscale hover:grayscale-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-[2.5rem] p-10 border border-dashed border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-black uppercase tracking-[0.2em] mb-4 dark:text-white italic">Status održavanja</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed italic">
|
||||
Ovaj stroj ({stroj.naziv}) je zadnji put atestiran {stroj.datum_zadnjeg_atesta}. Prema servisnom planu, toranjske dizalice zahtijevaju periodični pregled svakih 12 mjeseci. Trenutni radni sati ({stroj.radni_sati} h) sugeriraju potrebu za redovnim podmazivanjem sklopova.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<AkcijePanel tip="stroj" podaci={stroj} />
|
||||
|
||||
<!-- <div class="mt-8 bg-blue-600 rounded-[2.5rem] p-8 text-white shadow-2xl relative overflow-hidden group">
|
||||
<i class="fa-solid fa-file-pdf absolute -right-4 -top-4 text-7xl opacity-10"></i>
|
||||
<h4 class="text-sm font-black uppercase tracking-widest italic mb-4">Dokumentacija</h4>
|
||||
<div class="space-y-3 relative z-10">
|
||||
<button class="w-full text-left bg-white/10 hover:bg-white/20 p-4 rounded-xl border border-white/10 transition-all">
|
||||
<span class="text-[9px] font-black uppercase block opacity-70">Atestni list</span>
|
||||
<span class="text-xs font-bold italic">Preuzmi PDF</span>
|
||||
</button>
|
||||
<button class="w-full text-left bg-white/10 hover:bg-white/20 p-4 rounded-xl border border-white/10 transition-all">
|
||||
<span class="text-[9px] font-black uppercase block opacity-70">Tehnički podaci</span>
|
||||
<span class="text-xs font-bold italic">Otvori specifikaciju</span>
|
||||
</button>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
101
001.FRONTEND/src/pages/fleet/strojevi/index.astro
Normal file
101
001.FRONTEND/src/pages/fleet/strojevi/index.astro
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
// src/pages/fleet/strojevi/index.astro
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import AkcijePanel from "../../../components/AkcijePanel.astro";
|
||||
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
|
||||
import NaslovList from "../../../components/NaslovList.astro";
|
||||
import GenericKarticaItem from "../../../components/GenericKarticaItem.astro";
|
||||
|
||||
// Importiranje utilitija
|
||||
import { fetchStrojeviData, fetchCurrentUser } from "../../../lib/api";
|
||||
import { getStatusColorClass, formatStatus } from "../../../utils/ui";
|
||||
|
||||
// 1. DOHVAT FILTERA IZ URL-a
|
||||
const url = new URL(Astro.request.url);
|
||||
const vlasnikId = url.searchParams.get('vlasnik');
|
||||
|
||||
// 2. DOHVAT PODATAKA (Paralelno)
|
||||
const [{ strojevi }, user] = await Promise.all([
|
||||
fetchStrojeviData(vlasnikId),
|
||||
fetchCurrentUser()
|
||||
]);
|
||||
|
||||
// 3. LOGIKA ZA DINAMIČKE INFORMACIJE
|
||||
const imeKupca = strojevi.length > 0 && vlasnikId ? strojevi[0].vlasnik_naziv : null;
|
||||
const imeKorisnika = user?.first_name || "Serviser";
|
||||
const ulogaKorisnika = user?.is_serviser ? "Tehnička Služba" : "Logistika";
|
||||
|
||||
let dinamickiPodnaslov = "Pregled kompletne strojne flote i dizalica";
|
||||
if (vlasnikId && imeKupca) {
|
||||
dinamickiPodnaslov = `Prikaz strojeva dodijeljenih klijentu: ${imeKupca}`;
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title="Strojni park | Upravljanje">
|
||||
<div class="w-full space-y-10">
|
||||
|
||||
<!-- 1. WELCOME HEADER -->
|
||||
<WelcomeHeader
|
||||
textH1="Strojni park"
|
||||
ime={imeKorisnika}
|
||||
uloga={ulogaKorisnika}
|
||||
podnaslov={dinamickiPodnaslov}
|
||||
/>
|
||||
|
||||
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
|
||||
|
||||
<div class="md:col-span-7 space-y-6">
|
||||
|
||||
<!-- 2. NASLOV LISTE S GUMBOM ZA RESET FILTRA -->
|
||||
<div class="space-y-2">
|
||||
<NaslovList
|
||||
naslov={vlasnikId ? "Filtrirani strojevi" : "Tehničke jedinice"}
|
||||
ukupno={strojevi.length}
|
||||
label1="Ukupno"
|
||||
count1={strojevi.length}
|
||||
label2="Na terenu"
|
||||
count2={strojevi.length} // Ovdje možeš dodati filter za npr. 'u_radu'
|
||||
/>
|
||||
|
||||
{vlasnikId && (
|
||||
<div class="px-6">
|
||||
<a href="/fleet/strojevi" class="text-[9px] font-black text-red-500 uppercase tracking-widest hover:text-red-600 transition-colors flex items-center gap-2 italic">
|
||||
<i class="fa-solid fa-circle-xmark"></i> Ukloni filter klijenta
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- 3. GENERIČKA LISTA STROJEVA -->
|
||||
<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="strojevi-list">
|
||||
{strojevi.length > 0 ? strojevi.map((s) => (
|
||||
<GenericKarticaItem
|
||||
bojaTeme="indigo"
|
||||
href={`/fleet/strojevi/${s.id}`}
|
||||
ikona="fa-screwdriver-wrench"
|
||||
naslov={s.naziv}
|
||||
subNaslov={s.serijski_broj}
|
||||
metaTekst={`${s.vlasnik_naziv} | Radni sati: ${s.radni_sati}h`}
|
||||
status={s.status || 'planirano'}
|
||||
statusPrikaz={formatStatus(s.status || 'planirano')}
|
||||
statusBojaClass={getStatusColorClass(s.status || 'planirano')}
|
||||
/>
|
||||
)) : (
|
||||
<div class="p-24 text-center">
|
||||
<i class="fa-solid fa-box-open text-4xl text-gray-100 dark:text-gray-700 mb-4 block"></i>
|
||||
<p class="italic text-gray-400 text-[10px] uppercase tracking-widest leading-none">Nema strojeva u bazi podataka</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESNI PANEL -->
|
||||
<div class="md:col-span-3">
|
||||
<AkcijePanel tip="fleet" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
95
001.FRONTEND/src/pages/fleet/vozila/index.astro
Normal file
95
001.FRONTEND/src/pages/fleet/vozila/index.astro
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
// src/pages/fleet/vozila/index.astro
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import Button from "../../../components/Button.astro";
|
||||
import AkcijePanel from "../../../components/AkcijePanel.astro";
|
||||
import WelcomeHeader from "../../../components/WelcomeHeader.astro"; // Importamo novi header
|
||||
import NaslovList from "../../../components/NaslovList.astro";
|
||||
import GenericKarticaItem from "../../../components/GenericKarticaItem.astro";
|
||||
|
||||
// Importiranje utilitija
|
||||
import { fetchDashboardData, fetchCurrentUser } from "../../../lib/api";
|
||||
import { getStatusColorClass } from "../../../utils/ui";
|
||||
|
||||
// 1. DOHVAT PODATAKA
|
||||
// Paralelno dohvaćamo podatke o vozilima i trenutnom korisniku za Header
|
||||
const [dashboardData, user] = await Promise.all([
|
||||
fetchDashboardData(),
|
||||
fetchCurrentUser()
|
||||
]);
|
||||
|
||||
const vozila = dashboardData?.vozila || [];
|
||||
|
||||
// 2. LOGIKA STATISTIKE
|
||||
const aktivnaVozila = vozila.filter(v => v.status === 'aktivan').length;
|
||||
const naServisu = vozila.filter(v => v.status === 'servis').length;
|
||||
const neaktivnaVozila = vozila.filter(v => v.status === 'neaktivan').length;
|
||||
|
||||
// Podaci za WelcomeHeader
|
||||
const imeKorisnika = user?.first_name || "Serviser";
|
||||
// const ulogaKorisnika = user?.is_serviser ? "Terenski Tehničar" : "Logistika";
|
||||
---
|
||||
|
||||
<Layout title="Vozni park | Flota">
|
||||
<div class="w-full space-y-10">
|
||||
|
||||
<!-- WELCOME HEADER -->
|
||||
<WelcomeHeader />
|
||||
|
||||
<!-- LISTA VOZILA + -->
|
||||
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
|
||||
|
||||
<!-- LIJEVI STUPAC: POPIS -->
|
||||
<div class="md:col-span-7 space-y-6">
|
||||
<NaslovList
|
||||
naslov="Aktivna Flota"
|
||||
ukupno={vozila.length}
|
||||
label1="Spremni"
|
||||
filter1="planirano"
|
||||
count1={aktivnaVozila}
|
||||
label2="Servis"
|
||||
count2={naServisu}
|
||||
filter2="u_radu"
|
||||
label3="Neaktivni"
|
||||
count3={neaktivnaVozila}
|
||||
filter3="neaktivan"
|
||||
/>
|
||||
|
||||
<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="vozila-list">
|
||||
{vozila.length > 0 ? vozila.map((v) => (
|
||||
<GenericKarticaItem
|
||||
href={`/fleet/vozila/${v.id}`}
|
||||
status={v.status}
|
||||
statusBojaClass={getStatusColorClass(v.status)}
|
||||
ikona={v.status === 'aktivan' ? 'fa-truck' : 'fa-screwdriver-wrench'}
|
||||
naslov={v.naziv}
|
||||
subNaslov={v.registracija}
|
||||
metaTekst={`Prijavljeno: ${v.trenutni_kilometri.toLocaleString()} KM | Lokacija: Baza`}
|
||||
statusPrikaz={v.status_prikaz}
|
||||
/>
|
||||
)) : (
|
||||
<div class="p-24 text-center">
|
||||
<i class="fa-solid fa-truck-dash text-4xl text-gray-100 dark:text-gray-700 mb-4 block"></i>
|
||||
<p class="italic text-gray-400 text-[10px] uppercase tracking-widest leading-none">Baza podataka je prazna</p>
|
||||
<Button variant="outline" class="mt-6" id="btn-dodaj-prvo">Dodaj prvo vozilo</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESNI PANEL - AKCIJE -->
|
||||
<div class="md:col-span-3">
|
||||
<AkcijePanel />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { initFilters } from "../../../scripts/filters";
|
||||
initFilters();
|
||||
document.addEventListener('astro:after-swap', initFilters);
|
||||
</script>
|
||||
90
001.FRONTEND/src/pages/index.astro
Normal file
90
001.FRONTEND/src/pages/index.astro
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
// src/pages/index.astro
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import WelcomeHeader from "../components/WelcomeHeader.astro";
|
||||
import AkcijePanel from "../components/AkcijePanel.astro";
|
||||
import RadniNalogLista from "../components/RadniNalogLista.astro";
|
||||
import StatsGrid from "../components/StatsGrid.astro";
|
||||
import Kalendar from "../components/Kalendar/Kalendar.astro";
|
||||
|
||||
// API i Utils
|
||||
import { fetchDashboardData, fetchCurrentUser } from "../lib/api";
|
||||
|
||||
// 1. Dohvat podataka (paralelno radi brzine)
|
||||
const [data, user] = await Promise.all([
|
||||
fetchDashboardData(),
|
||||
fetchCurrentUser()
|
||||
]);
|
||||
|
||||
const nalozi = data?.nalozi || [];
|
||||
const imeKorisnika = user?.first_name || "Kolega";
|
||||
|
||||
// 2. Kalkulacija statistike samo za StatsGrid (ostalo ide u RadniNalogLista)
|
||||
const naloziURadu = nalozi.filter(n => n.status === 'u_radu').length;
|
||||
const planiraniNalozi = nalozi.filter(n => n.status === 'planirano').length;
|
||||
const zavrseniNalozi = nalozi.filter(n => n.status === 'zavrseno' || n.status === 'naplaceno').length;
|
||||
---
|
||||
|
||||
<Layout title="Dashboard | ServisLog">
|
||||
<div class="w-full space-y-5">
|
||||
|
||||
<!-- WELCOME HEADER -->
|
||||
<WelcomeHeader />
|
||||
|
||||
<!-- STATS GRID -->
|
||||
<div class="grid grid-cols-3 gap-2 sm:gap-6 px-2">
|
||||
<StatsGrid
|
||||
label="Planirano"
|
||||
value={planiraniNalozi}
|
||||
icon="fa-calendar-check"
|
||||
variant="blue"
|
||||
filterValue="planirano"
|
||||
/>
|
||||
<StatsGrid
|
||||
label="U radu"
|
||||
value={naloziURadu}
|
||||
icon="fa-screwdriver-wrench"
|
||||
variant="yellow"
|
||||
animate={true}
|
||||
filterValue="u_radu"
|
||||
/>
|
||||
<StatsGrid
|
||||
label="Gotovo"
|
||||
value={zavrseniNalozi}
|
||||
icon="fa-circle-check"
|
||||
variant="emerald"
|
||||
filterValue="zavrseno"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RADNI NALOZI LISTA + AKCIJE PANEL -->
|
||||
<div class="grid md:grid-cols-10 gap-8 items-start px-2 pb-10">
|
||||
|
||||
<div class="md:col-span-7 space-y-8">
|
||||
<RadniNalogLista
|
||||
limit={5}
|
||||
naslov="Zadnje aktivnosti"
|
||||
prikaziNaslov={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3 sticky top-28">
|
||||
<AkcijePanel tip="dashboard" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Kalendar />
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { initFilters } from "../scripts/filters.js";
|
||||
|
||||
// Inicijalizacija filtera za StatsGrid klikove
|
||||
initFilters();
|
||||
|
||||
// Re-inicijalizacija kod navigacije (Astro View Transitions)
|
||||
document.addEventListener('astro:after-swap', initFilters);
|
||||
</script>
|
||||
25
001.FRONTEND/src/pages/kalendar-dogadaja.astro
Normal file
25
001.FRONTEND/src/pages/kalendar-dogadaja.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
// src/pages/kalendar-dogadaja.astro
|
||||
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} />
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:gap-6 px-2">
|
||||
<Kalendar />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
131
001.FRONTEND/src/pages/kupci/[id].astro
Normal file
131
001.FRONTEND/src/pages/kupci/[id].astro
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
// src/pages/kupci/[id].astro
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AkcijePanel from "../../components/AkcijePanel.astro";
|
||||
import ListaStrojeva from "../../components/ListaStrojeva.astro";
|
||||
import { fetchStrojeviData, fetchRadniNalozi, fetchKupacDetalji, routes } from "../../lib/api";
|
||||
|
||||
const { id } = Astro.params;
|
||||
|
||||
// 1. Paralelno dohvaćanje podataka (brže renderiranje)
|
||||
// Koristimo status 'u_radu' jer smo utvrdili da Django ne prepoznaje 'otvoreno'
|
||||
const [strojeviData, kupac, nalozi] = await Promise.all([
|
||||
fetchStrojeviData(id),
|
||||
fetchKupacDetalji(id),
|
||||
fetchRadniNalozi({ klijent: id, status: 'u_radu' })
|
||||
]);
|
||||
|
||||
const strojevi = strojeviData?.strojevi || [];
|
||||
|
||||
// 2. Sigurnosni redirect ako kupac ne postoji
|
||||
if (!kupac) return Astro.redirect("/404");
|
||||
---
|
||||
|
||||
<Layout title={`Klijent | ${kupac.naziv}`}>
|
||||
<div class="w-full space-y-10">
|
||||
|
||||
<!-- 1. HEADER (Brutalistički stil) -->
|
||||
<header class="px-2 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||
<div class="space-y-4">
|
||||
<a href="/kupci/svi" class="text-indigo-600 text-[10px] font-black uppercase tracking-[0.3em] hover:underline flex items-center gap-2 no-underline mb-4">
|
||||
<i class="fa-solid fa-arrow-left text-[8px]"></i> Povratak klijentima
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-4xl sm:text-6xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none italic">
|
||||
{kupac.naziv}
|
||||
</h1>
|
||||
<div class="hidden sm:block px-4 py-1.5 bg-indigo-600 text-white rounded-full text-[10px] font-black uppercase tracking-widest italic">
|
||||
{kupac.tip || 'Partner'}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-500 dark:text-gray-400 font-medium italic">
|
||||
Glavno sjedište: <span class="text-indigo-600 font-bold">{kupac.grad}, {kupac.adresa}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- OIB Info box -->
|
||||
<div class="flex items-center gap-3 px-6 py-4 bg-white dark:bg-gray-800 rounded-[2rem] border border-gray-100 dark:border-gray-700 shadow-xl">
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-[8px] font-black uppercase text-gray-400 tracking-widest">Porezni broj</span>
|
||||
<span class="text-xl font-black text-gray-900 dark:text-white tracking-tighter italic">OIB: {kupac.oib}</span>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-gray-100 dark:bg-gray-700 mx-2"></div>
|
||||
<i class="fa-solid fa-address-card text-gray-200 text-2xl"></i>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2. QUICK STATS (Brojači) -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 px-2">
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden group">
|
||||
<i class="fa-solid fa-crane absolute -right-4 -bottom-4 text-6xl opacity-5 group-hover:rotate-12 transition-transform"></i>
|
||||
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest italic">Strojni Park</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-black dark:text-white italic tracking-tighter">{strojevi.length}</span>
|
||||
<span class="text-xs font-black text-indigo-600 uppercase">Jedinica</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Poveznica na filtriranu listu naloga -->
|
||||
<a
|
||||
href={routes.radniNalozi(id, 'u_radu')}
|
||||
class="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden group hover:border-indigo-500 transition-all no-underline block"
|
||||
>
|
||||
<i class="fa-solid fa-file-invoice absolute -right-4 -bottom-4 text-6xl opacity-5 group-hover:rotate-12 transition-transform text-indigo-600"></i>
|
||||
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest italic group-hover:text-indigo-600 transition-colors">
|
||||
Otvoreni radni Nalozi
|
||||
</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-black dark:text-white italic tracking-tighter">
|
||||
{String(nalozi?.length || 0)}
|
||||
</span>
|
||||
<i class="fa-solid fa-arrow-up-right-from-square text-[10px] text-indigo-600 opacity-0 group-hover:opacity-100 transition-opacity ml-2"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 3. MAIN CONTENT (Lista strojeva) -->
|
||||
<div class="grid md:grid-cols-10 gap-8 items-start px-2">
|
||||
<div class="md:col-span-7 space-y-12">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[3.5rem] p-4 md:p-8 border border-gray-100 dark:border-gray-700 shadow-2xl shadow-indigo-500/5">
|
||||
<ListaStrojeva
|
||||
strojevi={strojevi}
|
||||
naslov={`Operativna flota klijenta`}
|
||||
prikaziVlasnika={false}
|
||||
bojaTeme="indigo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-10 bg-gray-50 dark:bg-gray-900/50 rounded-[2.5rem] border border-dashed border-gray-200 dark:border-gray-700 flex justify-between items-center group">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-black uppercase dark:text-white italic tracking-widest">Napredna analitika voznog parka</h3>
|
||||
<p class="text-xs text-gray-500 italic">Pristupite detaljnim izvještajima i povijesti strojeva.</p>
|
||||
</div>
|
||||
<a href={`/fleet/strojevi?vlasnik=${id}`} class="bg-indigo-600 text-white p-4 rounded-2xl hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-500/20">
|
||||
<i class="fa-solid fa-arrow-right-long text-xl"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. RIGHT PANEL (Akcije i Detalji) -->
|
||||
<div class="md:col-span-3">
|
||||
<!-- AkcijePanel sada prima garantirani 'kupac' objekt -->
|
||||
<AkcijePanel tip="vlasnik" podaci={kupac} />
|
||||
|
||||
<div class="mt-8 bg-indigo-600 rounded-[2.5rem] p-8 text-white shadow-2xl relative overflow-hidden group">
|
||||
<i class="fa-solid fa-comments-dollar absolute -right-4 -top-4 text-7xl opacity-10"></i>
|
||||
<h4 class="text-sm font-black uppercase tracking-widest italic mb-4">Naplata i Ugovori</h4>
|
||||
<div class="space-y-3 relative z-10">
|
||||
<button class="w-full text-left bg-white/10 hover:bg-white/20 p-4 rounded-xl border border-white/10 transition-all">
|
||||
<span class="text-[9px] font-black uppercase block opacity-70">Uvjeti plaćanja</span>
|
||||
<span class="text-xs font-bold italic">30 dana (valuta)</span>
|
||||
</button>
|
||||
<button class="w-full text-left bg-white/10 hover:bg-white/20 p-4 rounded-xl border border-white/10 transition-all">
|
||||
<span class="text-[9px] font-black uppercase block opacity-70">Dokumentacija</span>
|
||||
<span class="text-xs font-bold italic">Preuzmi PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
97
001.FRONTEND/src/pages/kupci/svi.astro
Normal file
97
001.FRONTEND/src/pages/kupci/svi.astro
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
// src/pages/kupci/svi.astro
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AkcijePanel from "../../components/AkcijePanel.astro";
|
||||
import WelcomeHeader from "../../components/WelcomeHeader.astro";
|
||||
|
||||
// Importiranje nove funkcije
|
||||
import { fetchKupciData } from "../../lib/api";
|
||||
import { getStatusColorClass } from "../../utils/ui";
|
||||
|
||||
// 1. DOHVAT PODATAKA (Koristimo novu funkciju)
|
||||
const { kupci } = await fetchKupciData();
|
||||
|
||||
// 2. LOGIKA STATISTIKE
|
||||
const aktivniKupci = kupci.filter(k => k.aktivno === true).length;
|
||||
const brojPravnih = kupci.filter(k => k.tip === 'pravno').length;
|
||||
---
|
||||
|
||||
<Layout title="Partneri i Kupci | Servislog">
|
||||
<div class="w-full space-y-10">
|
||||
|
||||
<WelcomeHeader textH1="Baza" textH1dodatno="Partnera" podnaslov="Pregled klijenata, kontakata i strojne flote" />
|
||||
|
||||
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
|
||||
|
||||
<div class="md:col-span-7 space-y-6">
|
||||
<div class="flex justify-between items-center px-4">
|
||||
<h2 class="text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight italic">Popis klijenata</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[10px] font-bold uppercase text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20 px-2 py-1 rounded border border-emerald-200/50 italic">Aktivni: {aktivniKupci}</span>
|
||||
<span class="text-[10px] font-bold uppercase text-blue-600 bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded border border-blue-200/50 italic">Tvrtke: {brojPravnih}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
|
||||
<div id="kupci-list">
|
||||
{kupci.length > 0 ? kupci.map((k) => (
|
||||
<a
|
||||
href={`/kupci/${k.id}`}
|
||||
class="kupac-item flex items-center justify-between p-8 hover:bg-blue-50/30 dark:hover:bg-gray-700/30 border-b border-gray-50 dark:border-gray-700 last:border-0 group transition-all"
|
||||
>
|
||||
<div class="flex items-center gap-6 text-left">
|
||||
<div class="w-14 h-14 rounded-2xl bg-gray-50 dark:bg-gray-900 flex items-center justify-center text-gray-400 group-hover:bg-blue-600 group-hover:text-white transition-all shadow-sm">
|
||||
<i class={`fa-solid ${k.tip === 'pravno' ? 'fa-building-shield' : 'fa-user-gear'} text-xl`}></i>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-black text-gray-900 dark:text-white text-xl uppercase tracking-tighter italic group-hover:text-blue-600 transition-colors leading-none">
|
||||
{k.naziv}
|
||||
</span>
|
||||
<span class="text-[10px] font-black bg-gray-100 dark:bg-gray-900 px-2 py-0.5 rounded border border-gray-200 dark:border-gray-700 uppercase tracking-widest text-gray-500">
|
||||
{k.grad}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="text-gray-400 text-[10px] font-black uppercase tracking-widest mt-2">
|
||||
OIB: <span class="text-gray-900 dark:text-gray-200">{k.oib}</span>
|
||||
<span class="text-blue-600/30 mx-1">•</span>
|
||||
Kontakt: {k.telefon}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-8">
|
||||
<div class="hidden lg:flex flex-col items-end text-right">
|
||||
<span class="text-[8px] font-black uppercase text-gray-400 tracking-[0.2em]">Oprema u sustavu</span>
|
||||
<span class="text-xs font-black uppercase italic dark:text-gray-300">
|
||||
{k.broj_strojeva} Jedinica
|
||||
</span>
|
||||
</div>
|
||||
<i class="fa-solid fa-arrow-right-long text-gray-200 group-hover:text-blue-600 group-hover:translate-x-2 transition-all"></i>
|
||||
</div>
|
||||
</a>
|
||||
)) : (
|
||||
<div class="p-24 text-center">
|
||||
<i class="fa-solid fa-users-viewfinder text-4xl text-gray-100 dark:text-gray-700 mb-4 block"></i>
|
||||
<p class="italic text-gray-400 text-[10px] uppercase tracking-widest">Nema kupaca u bazi</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<AkcijePanel tip="dashboard" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { initFilters } from "../../scripts/filters";
|
||||
initFilters();
|
||||
document.addEventListener('astro:after-swap', initFilters);
|
||||
</script>
|
||||
18
001.FRONTEND/src/pages/login.astro
Normal file
18
001.FRONTEND/src/pages/login.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
// src/pages/login.astro
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import LoginForm from "../components/LoginForm.astro";
|
||||
import WelcomeHeader from "../components/WelcomeHeader.astro";
|
||||
---
|
||||
|
||||
<Layout title="Prijava u sustav">
|
||||
<div class="flex items-center justify-center bg-gray-50 dark:bg-gray-950 px-4">
|
||||
|
||||
<!-- WELCOME HEADER -->
|
||||
<WelcomeHeader />
|
||||
|
||||
<!-- LOGIN FORM -->
|
||||
<LoginForm />
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
181
001.FRONTEND/src/pages/operativa/radni-nalozi/[id].astro
Normal file
181
001.FRONTEND/src/pages/operativa/radni-nalozi/[id].astro
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
// src/pages/operativa/radni-nalozi/[id].astro
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import AkcijePanel from "../../../components/AkcijePanel.astro";
|
||||
import Gallery from "../../../components/Gallery.astro";
|
||||
|
||||
// Utiliti za vizualni prikaz
|
||||
import { getStatusColorClass } from "../../../utils/ui";
|
||||
|
||||
const { id } = Astro.params;
|
||||
const API_BASE = import.meta.env.PUBLIC_API_URL;
|
||||
|
||||
let nalog = null;
|
||||
|
||||
try {
|
||||
// Dohvaćamo detaljne podatke (RadniNalogDetaljiSerializer)
|
||||
const res = await fetch(`${API_BASE}/operativa/radni-nalozi/${id}/?t=${Date.now()}`);
|
||||
if (res.ok) nalog = await res.json();
|
||||
} catch (e) {
|
||||
console.error("Greška pri dohvatu detalja naloga:", e);
|
||||
}
|
||||
|
||||
if (!nalog) return Astro.redirect("/404");
|
||||
|
||||
// Priprema datuma za prikaz
|
||||
const datumKreiranja = new Date(nalog.datum_kreiranja).toLocaleDateString('hr-HR', {
|
||||
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
---
|
||||
|
||||
<Layout title={`Nalog #${nalog.broj_naloga}`}>
|
||||
<div class="w-full space-y-10 pb-20">
|
||||
|
||||
<!-- 1. HEADER SEKCIJA -->
|
||||
<header class="px-2 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||
<div>
|
||||
<a href="/" class="text-blue-600 text-[10px] font-black uppercase tracking-[0.3em] hover:underline flex items-center gap-2 mb-6 no-underline">
|
||||
<i class="fa-solid fa-arrow-left text-[8px]"></i> Povratak na dashboard
|
||||
</a>
|
||||
<h1 class="text-4xl md:text-6xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none">
|
||||
Radni nalog <span class="text-blue-600">#{nalog.broj_naloga}</span>
|
||||
</h1>
|
||||
<div class="flex flex-wrap items-center gap-4 mt-4">
|
||||
<p class="text-gray-500 dark:text-gray-400 font-medium italic text-sm">
|
||||
Otvoreno: {datumKreiranja}h
|
||||
</p>
|
||||
<span class="hidden sm:block text-gray-300 dark:text-gray-700">|</span>
|
||||
<p class="text-blue-600 dark:text-blue-400 font-black text-xs uppercase tracking-widest">
|
||||
<i class="fa-solid fa-user-gear mr-1"></i> {nalog.izvrsitelj_ime || 'Nedodijeljeno'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="flex items-center gap-3 px-6 py-3 bg-white dark:bg-gray-800 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-xl">
|
||||
<div class:list={[
|
||||
"w-3 h-3 rounded-full",
|
||||
getStatusColorClass(nalog.status),
|
||||
{"animate-pulse": nalog.status === 'u_radu'}
|
||||
]}></div>
|
||||
<span class="font-black uppercase text-xs tracking-widest text-gray-700 dark:text-gray-200">
|
||||
{nalog.status_display}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2. GLAVNI SADRŽAJ (Grid 10 stupaca) -->
|
||||
<div class="grid md:grid-cols-10 gap-8 items-start px-2">
|
||||
|
||||
<div class="md:col-span-7 space-y-8">
|
||||
|
||||
<!-- Kartica: Opis kvara -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[3rem] p-10 md:p-16 border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 relative overflow-hidden">
|
||||
<label class="text-[10px] font-black uppercase tracking-[0.3em] text-blue-600 block mb-6 italic">Opis kvara / Zadatak</label>
|
||||
<p class="text-2xl md:text-3xl font-medium text-gray-700 dark:text-gray-200 italic leading-tight tracking-tight">
|
||||
"{nalog.opis_kvara}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid: Stroj i Klijent -->
|
||||
<div class="grid sm:grid-cols-2 gap-6">
|
||||
<!-- STROJ (Tehnički podaci) -->
|
||||
<div class="bg-gray-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden group">
|
||||
<i class="fa-solid fa-screwdriver-wrench absolute -right-4 -bottom-4 text-8xl opacity-10 -rotate-12 group-hover:rotate-0 transition-transform duration-700"></i>
|
||||
<label class="text-[9px] font-black uppercase text-gray-500 block mb-4 italic tracking-widest">Stroj na popravku</label>
|
||||
<h3 class="text-2xl font-black uppercase tracking-tighter italic leading-none mb-2">{nalog.stroj.naziv}</h3>
|
||||
<span class="inline-block bg-blue-600 text-white px-3 py-1 rounded font-black text-[10px] tracking-widest uppercase mb-4">
|
||||
SN: {nalog.stroj?serijski_broj}
|
||||
</span>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs text-gray-400 font-medium uppercase">Radni sati: <span class="text-white font-black">{nalog.stroj.radni_sati} h</span></p>
|
||||
<p class="text-xs text-gray-400 font-medium uppercase">Lokacija: <span class="text-white font-black">{nalog.stroj.lokacija || 'Teren'}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KLIJENT (Kontakt podaci) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[2.5rem] p-8 border border-gray-100 dark:border-gray-700 shadow-sm">
|
||||
<label class="text-[9px] font-black uppercase text-gray-400 block mb-4 italic tracking-widest">Klijent / Vlasnik</label>
|
||||
<h3 class="text-2xl font-black uppercase tracking-tighter italic dark:text-white leading-none mb-2">{nalog.klijent.naziv}</h3>
|
||||
<p class="text-sm text-gray-500 font-medium">{nalog.klijent.grad} <span class="mx-1 text-blue-500">•</span> OIB: {nalog.klijent.oib}</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-4">
|
||||
<a href={`tel:${nalog.klijent.telefon}`} class="flex items-center gap-2 text-blue-600 font-black text-[10px] uppercase no-underline hover:opacity-70 transition-opacity">
|
||||
<div class="w-7 h-7 rounded-full bg-blue-50 flex items-center justify-center"><i class="fa-solid fa-phone"></i></div> Nazovi
|
||||
</a>
|
||||
<a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(nalog.klijent.adresa + ' ' + nalog.klijent.grad)}`} target="_blank" class="flex items-center gap-2 text-gray-500 font-black text-[10px] uppercase no-underline hover:opacity-70 transition-opacity">
|
||||
<div class="w-7 h-7 rounded-full bg-gray-50 flex items-center justify-center"><i class="fa-solid fa-location-dot"></i></div> Navigacija
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kartica: Logistika (Vozilo) -->
|
||||
{nalog.vozilo ? (
|
||||
<div class="bg-blue-50 dark:bg-blue-900/10 rounded-[2.5rem] p-8 border border-blue-100 dark:border-blue-900/30 flex flex-col sm:flex-row justify-between items-center gap-6">
|
||||
<div class="text-center sm:text-left">
|
||||
<label class="text-[9px] font-black uppercase text-blue-400 block mb-2 italic tracking-widest">Servisno Vozilo</label>
|
||||
<h3 class="text-xl font-black uppercase dark:text-white leading-tight">{nalog.vozilo.naziv}</h3>
|
||||
<span class="text-sm font-bold text-blue-600">{nalog.vozilo.registracija}</span>
|
||||
</div>
|
||||
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded-2xl border border-blue-100 dark:border-blue-900/20 shadow-sm text-center">
|
||||
<p class="text-[10px] text-gray-400 uppercase font-black mb-1">Početni KM</p>
|
||||
<p class="text-lg font-black text-blue-600 leading-none">
|
||||
{nalog.vozilo.trenutni_kilometri?.toLocaleString() || '0'} km
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="bg-gray-50 dark:bg-gray-900/30 rounded-[2.5rem] p-8 border border-dashed border-gray-200 dark:border-gray-800 flex items-center gap-4 opacity-60">
|
||||
<i class="fa-solid fa-walking text-gray-400"></i>
|
||||
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Radni nalog bez zaduženog vozila</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- SEKCIJA: GALERIJA (PhotoSwipe) -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<label class="text-[10px] font-black uppercase tracking-[0.3em] text-gray-400 italic">Foto dokumentacija terena</label>
|
||||
<span class="text-[10px] font-black bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full text-gray-500">
|
||||
{nalog.slike?.length || 0} SLIKA
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Gallery
|
||||
images={nalog.slike}
|
||||
galleryId={`gallery-rn-${nalog.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- DESNI PANEL (3 Stupca) -->
|
||||
<div class="md:col-span-3 space-y-6 sticky top-10">
|
||||
<!-- Komponenta za gumbe (Završi, Odgodi, Print...) -->
|
||||
<AkcijePanel tip="radni-nalog" podaci={nalog} />
|
||||
|
||||
<!-- Dodatni info o ažuriranju -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-[2rem] p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
|
||||
<h4 class="text-[9px] font-black uppercase text-gray-400 mb-4 tracking-widest">Zadnja aktivnost</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-xl bg-green-50 dark:bg-green-900/20 flex items-center justify-center text-green-600">
|
||||
<i class="fa-solid fa-clock-rotate-left text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] text-gray-400 uppercase font-bold leading-none mb-1">Ažurirano</p>
|
||||
<p class="text-xs font-black dark:text-gray-200 leading-none">
|
||||
{new Date(nalog.datum_azuriranja).toLocaleDateString('hr-HR')} u {new Date(nalog.datum_azuriranja).toLocaleTimeString('hr-HR', {hour:'2-digit', minute:'2-digit'})}h
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Kod ili ID -->
|
||||
<div class="text-center p-6 border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-[2rem]">
|
||||
<p class="text-[8px] font-black text-gray-300 uppercase tracking-[0.4em]">Sistemski ID: {nalog.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
66
001.FRONTEND/src/pages/operativa/radni-nalozi/index.astro
Normal file
66
001.FRONTEND/src/pages/operativa/radni-nalozi/index.astro
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
// src/pages/operativa/radni-nalozi/index.astro
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import Button from "../../../components/Button.astro";
|
||||
import AkcijePanel from "../../../components/AkcijePanel.astro";
|
||||
import RadniNalogLista from "../../../components/RadniNalogLista.astro";
|
||||
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
|
||||
import NaslovList from "../../../components/NaslovList.astro";
|
||||
|
||||
// 1. DOHVAT PODATAKA
|
||||
import { fetchRadniNalozi, fetchCurrentUser } from "../../../lib/api";
|
||||
import { formatStatus, getStatusColorClass } from "../../../utils/ui";
|
||||
import site from "../../../data/site.json";
|
||||
|
||||
// Paralelno dohvaćamo naloge i podatke o trenutnom korisniku
|
||||
const klijentId = Astro.url.searchParams.get('klijent');
|
||||
const statusParam = Astro.url.searchParams.get('status');
|
||||
|
||||
const [nalozi, user] = await Promise.all([
|
||||
fetchRadniNalozi({ klijent: klijentId, status: statusParam }),
|
||||
fetchCurrentUser()
|
||||
]);
|
||||
|
||||
// 2. LOGIKA ZA HEADER I BROJAČE
|
||||
const uRadu = nalozi.filter(n => n.status === 'u_radu').length;
|
||||
const planirano = nalozi.filter(n => n.status === 'planirano').length;
|
||||
const siteWelcomeHeader = site.navigation.find(({name}) => name === "Kalendar");
|
||||
|
||||
// Podaci za WelcomeHeader
|
||||
const imeKorisnika = user?.first_name || "Serviser";
|
||||
---
|
||||
|
||||
<Layout title="Radni nalozi | Arhiva">
|
||||
<div class="w-full space-y-10">
|
||||
|
||||
<!-- WELCOME HEADER -->
|
||||
<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">
|
||||
|
||||
<!-- 3. LISTA NALOGA -->
|
||||
<!-- Postavljamo prikaziNaslov={false} jer smo naslov već ispisali iznad preko NaslovList -->
|
||||
<RadniNalogLista
|
||||
limit={7}
|
||||
statusFilter={statusParam as any}
|
||||
prikaziNaslov={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- DESNI PANEL - AKCIJE -->
|
||||
<div class="md:col-span-3">
|
||||
<AkcijePanel tip="dashboard" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { initFilters } from "../../../scripts/filters";
|
||||
initFilters();
|
||||
document.addEventListener('astro:after-swap', initFilters);
|
||||
</script>
|
||||
213
001.FRONTEND/src/pages/operativa/radni-nalozi/novi.astro
Normal file
213
001.FRONTEND/src/pages/operativa/radni-nalozi/novi.astro
Normal file
@@ -0,0 +1,213 @@
|
||||
---
|
||||
// src/pages/operativa/radni-nalozi/novi.astro
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import Button from "../../../components/Button.astro";
|
||||
import { fetchKupciData, fetchStrojeviData } from "../../../lib/api";
|
||||
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
|
||||
|
||||
// 1. DOHVAT PODATAKA NA SERVERU
|
||||
const { kupci } = await fetchKupciData();
|
||||
const { strojevi } = await fetchStrojeviData();
|
||||
---
|
||||
|
||||
<Layout title="Novi Radni Nalog">
|
||||
<div class="max-w-3xl mx-auto space-y-8 px-2 py-10">
|
||||
|
||||
<WelcomeHeader />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6 border-b border-gray-100 dark:border-gray-700 pb-6 px-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Broj Naloga</label>
|
||||
<div id="broj-naloga-display" class="w-full bg-gray-100 dark:bg-gray-900/50 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-2xl p-5 font-black text-lg text-gray-400 flex items-center gap-3">
|
||||
<i class="fa-solid fa-barcode opacity-30"></i>
|
||||
<span id="broj-text">AUTO-GENERIRANO</span>
|
||||
</div>
|
||||
<p class="text-[9px] text-gray-400 ml-1 italic">* Broj će biti dodijeljen nakon spremanja</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Datum Kreiranja</label>
|
||||
<div class="w-full bg-gray-50 dark:bg-gray-900 border-2 border-transparent rounded-2xl p-5 font-bold text-lg dark:text-white flex items-center gap-3">
|
||||
<i class="fa-solid fa-calendar-day text-indigo-600"></i>
|
||||
{new Date().toLocaleDateString('hr-HR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="novi-nalog-form" class="space-y-6">
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-xl space-y-6">
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Odabir Klijenta *</label>
|
||||
<select name="klijent" required class="w-full bg-gray-50 dark:bg-gray-900 border-2 border-transparent focus:border-indigo-600 rounded-2xl p-5 font-bold text-lg appearance-none cursor-pointer transition-colors dark:text-white">
|
||||
<option value="">-- Odaberi klijenta --</option>
|
||||
{kupci.map(k => <option value={k.id}>{k.naziv}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Odabir Stroja *</label>
|
||||
<div class="custom-select-container relative w-full">
|
||||
<div id="stroj-trigger" class="w-full bg-gray-50 dark:bg-gray-900 border-2 border-transparent p-5 font-bold text-lg rounded-2xl cursor-pointer flex justify-between items-center dark:text-white">
|
||||
<span id="selected-stroj-label">-- Prvo odaberi klijenta --</span>
|
||||
<i class="fa-solid fa-chevron-down text-xs"></i>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="stroj" id="real-stroj-input" required />
|
||||
|
||||
<div id="stroj-options" class="hidden absolute z-50 w-full mt-2 bg-gray-900 border border-gray-700 rounded-2xl shadow-2xl overflow-hidden max-h-60 overflow-y-auto">
|
||||
{strojevi.map(s => (
|
||||
<div
|
||||
class="stroj-opt p-4 hover:bg-indigo-600 cursor-pointer transition-colors text-white font-bold italic border-b border-gray-800 last:border-0 hidden"
|
||||
data-value={s.id}
|
||||
data-vlasnik={s.vlasnik}
|
||||
>
|
||||
{s.naziv} ({s.registracija || s.serijski_broj})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-xl space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Opis problema / Radova *</label>
|
||||
<textarea name="opis_kvara" required rows="4" class="w-full bg-gray-50 dark:bg-gray-900 border-none rounded-3xl p-6 font-medium italic text-lg focus:ring-2 focus:ring-indigo-600 transition-all dark:text-white" placeholder="Što treba napraviti?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Trenutni Radni Sati</label>
|
||||
<input type="number" name="radni_sati" step="0.1" class="w-full bg-gray-50 dark:bg-gray-900 border-none rounded-2xl p-5 font-black text-2xl text-indigo-600 focus:ring-2 focus:ring-indigo-600 transition-all" placeholder="0.0">
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic block">Prioritet zahvata</label>
|
||||
<div class="flex gap-3 mt-2">
|
||||
<input type="checkbox" name="hitno" value="true" id="hitno" class="hidden peer">
|
||||
<label for="hitno" class="flex-1 text-center py-4 bg-gray-50 dark:bg-gray-900 rounded-2xl text-[10px] font-black uppercase peer-checked:bg-red-600 peer-checked:text-white cursor-pointer transition-all border-2 border-transparent peer-checked:border-red-700 dark:text-gray-400">
|
||||
<i class="fa-solid fa-triangle-exclamation mr-2"></i> Hitno
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-indigo-600 p-8 rounded-[3rem] shadow-xl text-white group relative overflow-hidden">
|
||||
<i class="fa-solid fa-camera absolute -right-4 -top-4 text-7xl opacity-10 group-hover:rotate-12 transition-transform duration-500"></i>
|
||||
<label class="text-[10px] font-black uppercase tracking-[0.2em] mb-4 block opacity-70 italic">Slike kvara/terena (opcionalno)</label>
|
||||
<input type="file" name="slike" multiple accept="image/*" class="w-full text-xs file:mr-4 file:py-3 file:px-6 file:rounded-full file:border-0 file:text-[10px] file:font-black file:uppercase file:bg-white file:text-indigo-600 hover:file:bg-indigo-50 cursor-pointer transition-all" />
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
variant="primary"
|
||||
class="w-full py-8 text-xl shadow-2xl shadow-indigo-500/20"
|
||||
>
|
||||
Kreiraj i pokreni nalog <i class="fa-solid fa-bolt ml-2"></i>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { createNalog, fetchSljedeciBrojNaloga } from "../../../lib/api";
|
||||
|
||||
const form = document.getElementById('novi-nalog-form') as HTMLFormElement;
|
||||
const klijentSelect = form.querySelector('select[name="klijent"]') as HTMLSelectElement;
|
||||
const trigger = document.getElementById('stroj-trigger');
|
||||
const optionsList = document.getElementById('stroj-options');
|
||||
const label = document.getElementById('selected-stroj-label');
|
||||
const realInput = document.getElementById('real-stroj-input') as HTMLInputElement;
|
||||
const strojOptions = document.querySelectorAll('.stroj-opt');
|
||||
|
||||
// Funkcija za inicijalizaciju broja naloga na klijentu
|
||||
async function initBrojNaloga() {
|
||||
const displayElement = document.getElementById('broj-text');
|
||||
if (displayElement) {
|
||||
const data = await fetchSljedeciBrojNaloga();
|
||||
displayElement.textContent = data.broj_naloga;
|
||||
}
|
||||
}
|
||||
|
||||
// Pozivamo funkciju čim se skripta učita
|
||||
initBrojNaloga();
|
||||
|
||||
// 1. DROPDOWN LOGIKA
|
||||
trigger?.addEventListener('click', () => {
|
||||
optionsList?.classList.toggle('hidden');
|
||||
trigger.classList.toggle('border-indigo-600');
|
||||
});
|
||||
|
||||
strojOptions.forEach(opt => {
|
||||
opt.addEventListener('click', () => {
|
||||
let val = opt.getAttribute('data-value');
|
||||
if (val) val = val.replace(/['"]+/g, '').trim();
|
||||
|
||||
if (label && realInput && optionsList) {
|
||||
label.textContent = opt.textContent;
|
||||
realInput.value = val || "";
|
||||
optionsList.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2. FILTRIRANJE STROJEVA
|
||||
klijentSelect?.addEventListener('change', (e) => {
|
||||
const klijentId = (e.target as HTMLSelectElement).value;
|
||||
if (label && realInput) {
|
||||
label.textContent = klijentId ? "-- Odaberi stroj --" : "-- Prvo odaberi klijenta --";
|
||||
realInput.value = "";
|
||||
}
|
||||
strojOptions.forEach(item => {
|
||||
const vlasnikId = item.getAttribute('data-vlasnik');
|
||||
if (vlasnikId == klijentId) item.classList.remove('hidden');
|
||||
else item.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// 3. SLANJE NA API
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
if (!btn) return;
|
||||
|
||||
btn.setAttribute('disabled', 'true');
|
||||
const originalContent = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-2"></i> Sinkronizacija...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Brišemo broj_naloga kako bi backend aktivirao save() logiku
|
||||
formData.delete('broj_naloga');
|
||||
|
||||
// Dodavanje izvrsitelja iz localStorage
|
||||
const userString = localStorage.getItem('user_info');
|
||||
if (userString) {
|
||||
const user = JSON.parse(userString);
|
||||
formData.append('izvrsitelj', user.id);
|
||||
}
|
||||
|
||||
const nalog = await createNalog(formData);
|
||||
|
||||
if (nalog && nalog.id) {
|
||||
window.location.href = `/operativa/radni-nalozi/${nalog.id}`;
|
||||
} else {
|
||||
btn.removeAttribute('disabled');
|
||||
btn.innerHTML = originalContent;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (trigger && optionsList && !trigger.contains(e.target as Node) && !optionsList.contains(e.target as Node)) {
|
||||
optionsList.classList.add('hidden');
|
||||
trigger.classList.remove('border-indigo-600');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
70
001.FRONTEND/src/scripts/filters.js
Normal file
70
001.FRONTEND/src/scripts/filters.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// // src/scripts/filters.js
|
||||
|
||||
// Upravlja sa item.style.display na osnovu aktivnog filtera i data-status atributa
|
||||
// Također upravlja sa stilovima kartica (ring, border) i naslovom filtera
|
||||
// export function initFilters() {
|
||||
// const cards = document.querySelectorAll('.stat-card');
|
||||
// const items = document.querySelectorAll('.nalog-item');
|
||||
// const title = document.getElementById('filter-title');
|
||||
// let activeFilter = null;
|
||||
|
||||
// cards.forEach(card => {
|
||||
// card.addEventListener('click', () => {
|
||||
// const filter = card.getAttribute('data-filter');
|
||||
|
||||
// if (activeFilter === filter) {
|
||||
// activeFilter = null;
|
||||
// items.forEach(item => item.style.display = 'flex');
|
||||
// cards.forEach(c => c.classList.remove('ring-4', 'ring-blue-600/20', 'border-blue-600'));
|
||||
// if (title) title.innerText = "Zadnje aktivnosti";
|
||||
// } else {
|
||||
// activeFilter = filter;
|
||||
// cards.forEach(c => c.classList.remove('ring-4', 'ring-blue-600/20', 'border-blue-600'));
|
||||
// card.classList.add('ring-4', 'ring-blue-600/20', 'border-blue-600');
|
||||
|
||||
// if (title) title.innerText = `Filtrirano: ${filter.replace('_', ' ')}`;
|
||||
|
||||
// items.forEach(item => {
|
||||
// const status = item.getAttribute('data-status');
|
||||
// item.style.display = (status === filter) ? 'flex' : 'none';
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// src/scripts/filters.js
|
||||
|
||||
export function initFilters() {
|
||||
const cards = document.querySelectorAll('.stat-card');
|
||||
|
||||
// Dohvaćamo trenutni aktivni filter iz URL-a
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const currentActiveFilter = urlParams.get('status');
|
||||
|
||||
cards.forEach(card => {
|
||||
const filterValue = card.getAttribute('data-filter');
|
||||
|
||||
// Dodaj vizualno aktivno stanje ako se URL podudara s gumbom
|
||||
if (currentActiveFilter === filterValue) {
|
||||
card.classList.add('ring-4', 'ring-blue-600/20', 'border-blue-600');
|
||||
}
|
||||
|
||||
card.addEventListener('click', () => {
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (currentActiveFilter === filterValue) {
|
||||
// Ako kliknemo na već aktivni filter, poništavamo ga
|
||||
newParams.delete('status');
|
||||
} else {
|
||||
// Inače postavljamo novi filter
|
||||
newParams.set('status', filterValue);
|
||||
}
|
||||
|
||||
// Osvježi stranicu s novim parametrom - to okida novi API request na serveru
|
||||
window.location.search = newParams.toString();
|
||||
});
|
||||
});
|
||||
}
|
||||
1
001.FRONTEND/src/styles/global.css
Normal file
1
001.FRONTEND/src/styles/global.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
111
001.FRONTEND/src/utils/ui.js
Normal file
111
001.FRONTEND/src/utils/ui.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// src/utils/ui.js
|
||||
|
||||
/**
|
||||
* Vraća set Tailwind klasa za specifičnu temu
|
||||
* @param {string} tema - 'indigo', 'blue', 'emerald', 'amber', 'red'
|
||||
*/
|
||||
export function getThemeClasses(tema = "indigo") {
|
||||
const themes = {
|
||||
indigo: {
|
||||
bg: "bg-indigo-50",
|
||||
bgDark: "dark:bg-indigo-900/20",
|
||||
text: "text-indigo-600",
|
||||
textDark: "dark:text-indigo-400",
|
||||
border: "border-indigo-100",
|
||||
borderDark: "dark:border-indigo-900/30",
|
||||
shadow: "shadow-indigo-500/5",
|
||||
hover: "hover:bg-indigo-50/30 dark:hover:bg-indigo-900/10",
|
||||
icon: "group-hover:bg-indigo-600 group-hover:text-white"
|
||||
},
|
||||
blue: {
|
||||
bg: "bg-blue-50",
|
||||
bgDark: "dark:bg-blue-900/20",
|
||||
text: "text-blue-600",
|
||||
textDark: "dark:text-blue-400",
|
||||
border: "border-blue-100",
|
||||
borderDark: "dark:border-blue-900/30",
|
||||
shadow: "shadow-blue-500/5",
|
||||
hover: "hover:bg-blue-50/30 dark:hover:bg-blue-900/10",
|
||||
icon: "group-hover:bg-blue-600 group-hover:text-white"
|
||||
},
|
||||
emerald: {
|
||||
bg: "bg-emerald-50",
|
||||
bgDark: "dark:bg-emerald-900/20",
|
||||
text: "text-emerald-600",
|
||||
textDark: "dark:text-emerald-400",
|
||||
border: "border-emerald-100",
|
||||
borderDark: "dark:border-emerald-900/30",
|
||||
shadow: "shadow-emerald-500/5",
|
||||
hover: "hover:bg-emerald-50/30 dark:hover:bg-emerald-900/10",
|
||||
icon: "group-hover:bg-emerald-600 group-hover:text-white"
|
||||
},
|
||||
amber: {
|
||||
bg: "bg-amber-50",
|
||||
bgDark: "dark:bg-amber-900/20",
|
||||
text: "text-amber-600",
|
||||
textDark: "dark:text-amber-400",
|
||||
border: "border-amber-100",
|
||||
borderDark: "dark:border-amber-900/30",
|
||||
shadow: "shadow-amber-500/5",
|
||||
hover: "hover:bg-amber-50/30 dark:hover:bg-amber-900/10",
|
||||
icon: "group-hover:bg-amber-600 group-hover:text-white"
|
||||
},
|
||||
red: {
|
||||
bg: "bg-red-50",
|
||||
bgDark: "dark:bg-red-900/20",
|
||||
text: "text-red-600",
|
||||
textDark: "dark:text-red-400",
|
||||
border: "border-red-100",
|
||||
borderDark: "dark:border-red-900/30",
|
||||
shadow: "shadow-red-500/5",
|
||||
hover: "hover:bg-red-50/30 dark:hover:bg-red-900/10",
|
||||
icon: "group-hover:bg-red-600 group-hover:text-white"
|
||||
}
|
||||
};
|
||||
|
||||
return themes[tema] || themes.indigo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Određuje boju kružića i animaciju na temelju statusa (Vozila + Radni Nalozi)
|
||||
*/
|
||||
export function getStatusColorClass(status) {
|
||||
// 1. Provjera postoji li status. Ako ne, vrati sivu boju.
|
||||
if (!status) return 'bg-gray-400';
|
||||
|
||||
// 2. Siguran poziv toLowerCase()
|
||||
const s = String(status).toLowerCase();
|
||||
|
||||
switch (s) {
|
||||
case 'u_radu': return 'bg-yellow-500 animate-pulse';
|
||||
case 'planirano':
|
||||
case 'aktivan': return 'bg-blue-500';
|
||||
case 'zavrseno': return 'bg-emerald-500';
|
||||
case 'naplaceno': return 'bg-gray-500 opacity-50';
|
||||
case 'servis':
|
||||
case 'hitno': return 'bg-red-500 animate-pulse';
|
||||
default: return 'bg-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretvara tehnički status u ljudima čitljiv format
|
||||
* Dodana provjera da spriječi toLowerCase error
|
||||
*/
|
||||
export function formatStatus(status) {
|
||||
// Provjera ako je status prazan
|
||||
if (!status) return 'Nepoznato';
|
||||
|
||||
const labels = {
|
||||
'planirano': 'Planirano',
|
||||
'u_radu': 'U radu',
|
||||
'zavrseno': 'Završeno',
|
||||
'naplaceno': 'Naplaćeno',
|
||||
'aktivan': 'Aktivan',
|
||||
'servis': 'Na servisu',
|
||||
'hitno': 'HITNO'
|
||||
};
|
||||
|
||||
const s = String(status).toLowerCase();
|
||||
return labels[s] || s;
|
||||
}
|
||||
13
001.FRONTEND/tailwind.config.mjs
Normal file
13
001.FRONTEND/tailwind.config.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}',
|
||||
'./node_modules/flowbite/**/*.js' // Ovo omogućuje Flowbite stilove
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('flowbite/plugin') // Registrira Flowbite kao plugin
|
||||
],
|
||||
}
|
||||
5
001.FRONTEND/tsconfig.json
Normal file
5
001.FRONTEND/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user