4 Commits

Author SHA1 Message Date
mariomitte
8485431180 fix: zamijeni auth polling health endpointom
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaje backend /api/health/ endpoint i prebacuje frontend heartbeat sa /api/token/ OPTIONS na /api/health/ GET kako bi se izbjeglo periodicko gadanje auth rute.

Dodaje TTL cache za clientStore (lastFetchedAt + force opcija) kako bi se smanjili redundantni crm/clients pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 16:27:20 +02:00
mariomitte
bbdd02c9e9 fix: stabiliziraj navbar tranziciju i ukloni white splash
Zadrzava Navbar i ostale UI otoke kroz Astro tranzicije te sinkronizira aktivni path preko astro:page-load kako bi react-spring indikator animirao iz trenutne pozicije.

Uklanja bijeli splash pri promjeni stranice i prelasku teme postavljanjem html background boje inline, prilagodbom ThemeToggle logike i uklanjanjem body color tranzicije.

Dodano je i TTL kesiranje za dashboard/task dohvat kako bi se smanjili redundantni API pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 16:18:15 +02:00
mariomitte
fe7edf5643 fix: premjesti kalendar widget u Layout, popravi PDF toast i Servisi danas
- TaskCalendarPortal u Layout.astro (van AnimatedPage transforma) pa fixed
  pozicija uvijek prati viewport rub bez probijanja sadržaja
- Kalendar widgeta panel koristi clipping wrapper (overflow:hidden + animirana
  width) umjesto translate3d — potpuno skriva sjenu i border kad je zatvoren
- Serviser vidi samo svoje taskove, supervisor vidi sve; neprijavljeni korisnik
  ne vidi widget (return null)
- FleetDashboardShell sluša calendar:open-task event i otvara TaskServiceRecordsModal
- Servisi danas broji Taskove (scheduled_date=danas, status aktivan/servis)
  umjesto Servisnih zapisa
- downloadGeneratedPdfByUrl hvata grešku i prikazuje Toast (npr. PDF nije
  dostupan ili je istekao)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 15:27:20 +02:00
mariomitte
0c9e1b7d50 feat: dodaj scheduled_date na Task i kalendar widget
- Novo polje Task.scheduled_date (DateField, nullable, db_index) za planiranje
- Migracija 0009_task_scheduled_date
- Celery task notify_upcoming_tasks: dnevno u 08:00 šalje Pusher + email za sutrašnje zadatke
- TaskCalendarWidget: react-spring slide panel s lijeve strane, grid prikaz mjeseca, dot indikatori, klik otvara TaskServiceRecordsModal
- FleetDashboardShell: import + render kalendara, Planirano stupac u tablici zadataka
- TaskCreateModal + TaskServiceRecordsModal: scheduled_date input i prikaz
- Admin + serializer + views: scheduled_date filter i prikaz
- 47 backend testova prolaze (uključuje 8 novih za notify_upcoming_tasks)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 14:52:10 +02:00
27 changed files with 852 additions and 53 deletions

View File

@@ -175,6 +175,10 @@ CELERY_BEAT_SCHEDULE = {
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task', 'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
'schedule': crontab(minute=0), 'schedule': crontab(minute=0),
}, },
'notify-upcoming-tasks-daily': {
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
'schedule': crontab(hour=8, minute=0),
},
} }
# URL kojim će preglednik dohvaćati statiku # URL kojim će preglednik dohvaćati statiku

View File

@@ -2,14 +2,19 @@
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.http import JsonResponse
from django.urls import path, include from django.urls import path, include
from rest_framework_simplejwt.views import ( from rest_framework_simplejwt.views import (
TokenObtainPairView, TokenObtainPairView,
TokenRefreshView, TokenRefreshView,
) )
def api_health(_request):
return JsonResponse({'status': 'ok'})
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/health/', api_health, name='api_health'),
# Uključujemo rute iz modula (svaki modul ima svoj urls.py) # Uključujemo rute iz modula (svaki modul ima svoj urls.py)
path('api/crm/', include('modules.crm.urls')), path('api/crm/', include('modules.crm.urls')),

View File

View File

@@ -250,6 +250,7 @@ class WorkOrder(BaseModel):
('defektaza', _("Defektaža")), ('defektaza', _("Defektaža")),
('kontrola', _("Kontrola")), ('kontrola', _("Kontrola")),
('redovni_pregled', _("Redovni pregled")), ('redovni_pregled', _("Redovni pregled")),
('popravak', _("Popravak")),
] ]
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo")) vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))

View File

@@ -26,8 +26,8 @@ class TaskTemplateEntryInline(admin.TabularInline):
@admin.register(Task) @admin.register(Task)
class TaskAdmin(admin.ModelAdmin): class TaskAdmin(admin.ModelAdmin):
list_display = ('title', 'status', 'assigned_to', 'vehicle', 'work_order', 'created_at') list_display = ('title', 'status', 'scheduled_date', 'assigned_to', 'vehicle', 'work_order', 'created_at')
list_filter = ('status', 'created_at', 'work_order') list_filter = ('status', 'scheduled_date', 'created_at', 'work_order')
search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number') search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number')
readonly_fields = ('id', 'created_at', 'updated_at') readonly_fields = ('id', 'created_at', 'updated_at')
inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline) inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline)

View File

@@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0008_task_add_ready_for_completion_status'),
]
operations = [
migrations.AddField(
model_name='task',
name='scheduled_date',
field=models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name='Planirani datum izvršenja',
help_text='Datum kada je planirano izvršenje radnog zadatka.',
),
),
]

View File

@@ -47,6 +47,13 @@ class Task(BaseModel):
related_name='work_order_tasks', related_name='work_order_tasks',
verbose_name=_("Putni nalog"), verbose_name=_("Putni nalog"),
) )
scheduled_date = models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name=_("Planirani datum izvršenja"),
help_text=_("Datum kada je planirano izvršenje radnog zadatka."),
)
class Meta: class Meta:
ordering = ['-created_at'] ordering = ['-created_at']

View File

@@ -96,6 +96,7 @@ class TaskSerializer(serializers.ModelSerializer):
'assigned_to', 'assigned_to_name', 'assigned_to', 'assigned_to_name',
'vehicle', 'vehicle_registration', 'vehicle', 'vehicle_registration',
'work_order', 'work_order_label', 'work_order', 'work_order_label',
'scheduled_date',
'template_id', 'auto_close_work_order', 'template_id', 'auto_close_work_order',
'created_at', 'created_at',
] ]
@@ -106,6 +107,7 @@ class TaskSerializer(serializers.ModelSerializer):
'status': {'required': False}, 'status': {'required': False},
'work_order': {'required': False, 'allow_null': True}, 'work_order': {'required': False, 'allow_null': True},
'vehicle': {'required': False, 'allow_null': True}, 'vehicle': {'required': False, 'allow_null': True},
'scheduled_date': {'required': False, 'allow_null': True},
} }
def validate_title(self, value): def validate_title(self, value):

View File

@@ -1,10 +1,64 @@
# backend/modules/task_management/tasks.py # backend/modules/task_management/tasks.py
import logging
from celery import shared_task from celery import shared_task
from .models import Task from .models import Task
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from modules.fleet.models import Vehicle, WorkOrder from modules.fleet.models import Vehicle, WorkOrder
logger = logging.getLogger(__name__)
@shared_task
def notify_upcoming_tasks():
"""
Dnevna Celery periodik task koji šalje Pusher + email notifikacije
za radne zadatke čiji je planirani datum izvršenja sutra.
"""
from datetime import date, timedelta
from modules.fleet.services import NotificationService
tomorrow = date.today() + timedelta(days=1)
upcoming = Task.objects.filter(
is_active=True,
scheduled_date=tomorrow,
status__in=('aktivan', 'servis'),
).select_related('assigned_to', 'vehicle')
notified = 0
for task in upcoming:
recipient = task.assigned_to
if not recipient:
continue
vehicle_label = (
task.vehicle.registration_number if task.vehicle else 'nepoznato'
)
try:
NotificationService.create_notification(
recipient=recipient,
title=f"Sutra planirani zadatak: {task.title}",
message=(
f"Radni zadatak '{task.title}' na dizalici {vehicle_label} "
f"je planiran za sutra ({tomorrow.strftime('%d.%m.%Y')})."
),
level="info",
send_email=True,
metadata={
"entity_type": "task",
"task_id": str(task.id),
"vehicle_id": str(task.vehicle_id) if task.vehicle_id else None,
"section": "dashboard",
"scheduled_date": tomorrow.isoformat(),
},
)
notified += 1
except Exception:
logger.exception("Greška pri slanju notifikacije za task %s", task.id)
logger.info("notify_upcoming_tasks: obaviješteno %d servisera za datum %s", notified, tomorrow)
return {"notified": notified, "date": tomorrow.isoformat()}
@shared_task @shared_task
def create_task_from_invoice(invoice_data): def create_task_from_invoice(invoice_data):
""" """

View File

@@ -1,5 +1,6 @@
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
import datetime
from modules.task_management.models import Task from modules.task_management.models import Task
@@ -9,4 +10,25 @@ class TaskModelTests(TestCase):
user = get_user_model().objects.create_user(username="u1", email="u1@example.test", password="pass") user = get_user_model().objects.create_user(username="u1", email="u1@example.test", password="pass")
task = Task.objects.create(title="My Task", description="Desc", assigned_to=user) task = Task.objects.create(title="My Task", description="Desc", assigned_to=user)
assert str(task).startswith("My Task") assert str(task).startswith("My Task")
assert "aktivan" in str(task) or "(" in str(task) # basic sanity assert "aktivan" in str(task) or "(" in str(task)
def test_scheduled_date_defaults_to_null(self):
user = get_user_model().objects.create_user(username="u2", email="u2@example.test", password="pass")
task = Task.objects.create(title="Task bez datuma", assigned_to=user)
assert task.scheduled_date is None
def test_scheduled_date_is_saved_and_retrieved(self):
user = get_user_model().objects.create_user(username="u3", email="u3@example.test", password="pass")
target = datetime.date(2026, 8, 15)
task = Task.objects.create(title="Planirani task", assigned_to=user, scheduled_date=target)
task.refresh_from_db()
assert task.scheduled_date == target
def test_scheduled_date_can_be_updated_to_null(self):
user = get_user_model().objects.create_user(username="u4", email="u4@example.test", password="pass")
target = datetime.date(2026, 8, 15)
task = Task.objects.create(title="Task s datumom", assigned_to=user, scheduled_date=target)
task.scheduled_date = None
task.save()
task.refresh_from_db()
assert task.scheduled_date is None

View File

@@ -44,3 +44,40 @@ class TaskSerializerTests(TestCase):
serializer = TaskSerializer(instance=task, data={"status": "zavrsen", "work_order": str(work_order.id)}, partial=True) serializer = TaskSerializer(instance=task, data={"status": "zavrsen", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors assert serializer.is_valid(), serializer.errors
def test_scheduled_date_is_optional_on_create(self):
serializer = TaskSerializer(data={"title": "Task planned", "status": "aktivan", "vehicle": str(self.vehicle.id)})
assert serializer.is_valid(), serializer.errors
assert 'scheduled_date' not in serializer.errors
def test_scheduled_date_is_included_in_serialized_output(self):
import datetime
task = Task.objects.create(
title="Task with date",
assigned_to=self.user,
vehicle=self.vehicle,
scheduled_date=datetime.date(2026, 9, 1),
)
serializer = TaskSerializer(instance=task)
assert 'scheduled_date' in serializer.data
assert str(serializer.data['scheduled_date']) == '2026-09-01'
def test_scheduled_date_can_be_null_in_serialized_output(self):
task = Task.objects.create(title="Task no date", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task)
assert serializer.data['scheduled_date'] is None
def test_scheduled_date_accepts_valid_date(self):
serializer = TaskSerializer(
data={"title": "Task mit datum", "status": "aktivan",
"vehicle": str(self.vehicle.id), "scheduled_date": "2026-12-25"}
)
assert serializer.is_valid(), serializer.errors
def test_scheduled_date_rejects_invalid_date_string(self):
serializer = TaskSerializer(
data={"title": "Task bad date", "status": "aktivan",
"vehicle": str(self.vehicle.id), "scheduled_date": "not-a-date"}
)
assert not serializer.is_valid()
assert 'scheduled_date' in serializer.errors

View File

@@ -0,0 +1,133 @@
import datetime
from unittest.mock import patch
from django.test import TestCase
from django.contrib.auth import get_user_model
from modules.task_management.tasks import notify_upcoming_tasks
from modules.task_management.models import Task
from modules.fleet.models import Vehicle, WorkOrder
class NotifyUpcomingTasksTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="serviser-notif",
email="serviser@example.test",
password="pass",
)
self.vehicle = Vehicle.objects.create(
registration_number="ZG-NOTIF-01",
asset_type='crane',
)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_notifies_for_task_scheduled_tomorrow(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(
title="Sutrašnji zadatak",
assigned_to=self.user,
vehicle=self.vehicle,
status='aktivan',
scheduled_date=tomorrow,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 1)
mock_notify.assert_called_once()
call_kwargs = mock_notify.call_args.kwargs
self.assertEqual(call_kwargs['recipient'], self.user)
self.assertIn('Sutrašnji zadatak', call_kwargs['title'])
self.assertEqual(call_kwargs['level'], 'info')
self.assertTrue(call_kwargs.get('send_email'))
@patch('modules.fleet.services.NotificationService.create_notification')
def test_notifies_multiple_upcoming_tasks(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(title="Task A", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=tomorrow)
Task.objects.create(title="Task B", assigned_to=self.user, vehicle=self.vehicle,
status='servis', scheduled_date=tomorrow)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 2)
self.assertEqual(mock_notify.call_count, 2)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_without_scheduled_date(self, mock_notify):
Task.objects.create(
title="Bez datuma", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=None,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_scheduled_today(self, mock_notify):
today = datetime.date.today()
Task.objects.create(
title="Danas", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=today,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_scheduled_in_past(self, mock_notify):
yesterday = datetime.date.today() - datetime.timedelta(days=1)
Task.objects.create(
title="Jučer", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=yesterday,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_finished_task(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
vehicle2 = Vehicle.objects.create(registration_number="ZG-NOTIF-02", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle2, creator=self.user, purpose='defektaza',
status='closed', start_mileage=100, end_mileage=200,
)
Task.objects.create(
title="Završeni", assigned_to=self.user, vehicle=vehicle2,
status='zavrsen', scheduled_date=tomorrow, work_order=work_order,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_cancelled_task(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
vehicle3 = Vehicle.objects.create(registration_number="ZG-NOTIF-03", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle3, creator=self.user, purpose='kontrola',
status='closed', start_mileage=50, end_mileage=100,
)
Task.objects.create(
title="Otkazani", assigned_to=self.user, vehicle=vehicle3,
status='neaktivan', scheduled_date=tomorrow, work_order=work_order,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_without_assigned_user(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(
title="Bez servisera", assigned_to=None, vehicle=self.vehicle,
status='aktivan', scheduled_date=tomorrow,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_result_includes_date_string(self, mock_notify):
result = notify_upcoming_tasks()
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
self.assertEqual(result['date'], tomorrow.isoformat())
self.assertEqual(result['notified'], 0)

View File

@@ -27,6 +27,9 @@ class TaskViewSet(viewsets.ModelViewSet):
vehicle_id = self.request.query_params.get('vehicle') vehicle_id = self.request.query_params.get('vehicle')
if vehicle_id: if vehicle_id:
qs = qs.filter(vehicle_id=vehicle_id) qs = qs.filter(vehicle_id=vehicle_id)
scheduled_date = self.request.query_params.get('scheduled_date')
if scheduled_date:
qs = qs.filter(scheduled_date=scheduled_date)
return qs return qs
def perform_create(self, serializer): def perform_create(self, serializer):

View File

@@ -2,7 +2,8 @@ import NotificationBell from './NotificationBell';
import ThemeToggle from './ui/ThemeToggle'; import ThemeToggle from './ui/ThemeToggle';
import AuthWidget from './AuthWidget'; import AuthWidget from './AuthWidget';
import UserDisplay from './ui/UserDisplay'; import UserDisplay from './ui/UserDisplay';
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useSpring, animated } from '@react-spring/web';
import { hydrateAuthFromStorage } from '../stores/authStore'; import { hydrateAuthFromStorage } from '../stores/authStore';
const ITEMS = [ const ITEMS = [
@@ -13,27 +14,82 @@ const ITEMS = [
{ id: 'clients', label: 'Klijenti', href: '/clients' }, { id: 'clients', label: 'Klijenti', href: '/clients' },
]; ];
export default function Navbar({ minimal = false }) { function normalizePath(p) {
const [pathname, setPathname] = useState('/'); const value = String(p || '/');
const normalizedPath = useMemo(() => { if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
const value = String(pathname || '/'); return value;
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1); }
return value;
}, [pathname]);
function getActiveIndex(path) {
const idx = ITEMS.findIndex((item) => item.href === path);
return idx >= 0 ? idx : 0;
}
export default function Navbar({ minimal = false }) {
// Navbar ostaje montiran (transition:persist) — pratimo promjene pathname preko astro:page-load
const [pathname, setPathname] = useState(() =>
typeof window !== 'undefined' ? normalizePath(window.location.pathname) : '/'
);
const itemRefs = useRef([]);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
// Na prvom renderu preskočimo animaciju (pill se pojavljuje na ispravnoj poziciji bez slide-a od 0)
const [skipAnimation, setSkipAnimation] = useState(true);
const activeIndex = getActiveIndex(pathname);
const measureIndicator = (currentIndex) => {
const idx = currentIndex ?? activeIndex;
const el = itemRefs.current[idx];
if (!el) return;
const parent = el.closest('ul');
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
setIndicatorStyle({
left: elRect.left - parentRect.left,
width: elRect.width,
});
};
// Mount: izmjeri početnu poziciju bez animacije, pa uključi animaciju za buduće navigacije
useEffect(() => { useEffect(() => {
const updatePath = () => setPathname(window.location.pathname || '/');
updatePath();
window.addEventListener('astro:page-load', updatePath);
// Hidratira auth iz storage pri prvi puta učitavanja Navbar-a
hydrateAuthFromStorage(); hydrateAuthFromStorage();
return () => window.removeEventListener('astro:page-load', updatePath);
const timer = setTimeout(() => {
measureIndicator();
setSkipAnimation(false);
}, 30);
// Sluša Astro view-transition navigaciju (Navbar ostaje montiran zahvaljujući transition:persist)
const handlePageLoad = () => {
setPathname(normalizePath(window.location.pathname));
};
document.addEventListener('astro:page-load', handlePageLoad);
return () => {
clearTimeout(timer);
document.removeEventListener('astro:page-load', handlePageLoad);
};
}, []); }, []);
const currentItem = useMemo( // Kad se promijeni activeIndex (nova stranica), animiraj pill na novu poziciju
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0], useEffect(() => {
[normalizedPath] if (!skipAnimation) {
); measureIndicator();
}
}, [activeIndex]);
// react-spring: animirani klizač
// immediate=true na prvom renderu → pill se ne animira od lijevog ruba, već skače na pravo mjesto
const springStyle = useSpring({
left: indicatorStyle.left,
width: indicatorStyle.width,
immediate: skipAnimation,
config: { tension: 340, friction: 28 },
});
const currentItem = ITEMS[activeIndex];
return ( return (
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur"> <nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
@@ -44,16 +100,29 @@ export default function Navbar({ minimal = false }) {
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />} {!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
</a> </a>
{/* Navigacijski linkovi */} {/* Navigacijski linkovi s react-spring klizačem */}
<ul className="hidden items-center gap-1 text-sm sm:flex"> <ul className="relative hidden items-center gap-1 text-sm sm:flex">
{ITEMS.map((item) => ( {/* Animirani pozadinski klizač */}
<li key={item.id}> <animated.li
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
bottom: 0,
borderRadius: '0.5rem',
backgroundColor: 'rgb(238 242 255)',
pointerEvents: 'none',
...springStyle,
}}
/>
{ITEMS.map((item, idx) => (
<li key={item.id} ref={(el) => (itemRefs.current[idx] = el)}>
<a <a
href={item.href} href={item.href}
className={ className={
normalizedPath === item.href pathname === item.href
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700' ? 'relative z-10 block rounded-lg px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep' : 'relative z-10 block rounded-lg px-3 py-2 text-text-main hover:text-indigo-600'
} }
> >
{item.label} {item.label}
@@ -64,6 +133,7 @@ export default function Navbar({ minimal = false }) {
{/* Desna strana */} {/* Desna strana */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Mobilni dropdown */}
<details className="relative sm:hidden"> <details className="relative sm:hidden">
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep"> <summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
{currentItem?.label || 'Izbornik'} {currentItem?.label || 'Izbornik'}
@@ -78,7 +148,7 @@ export default function Navbar({ minimal = false }) {
<a <a
href={item.href} href={item.href}
className={ className={
normalizedPath === item.href pathname === item.href
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700' ? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep' : 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
} }

View File

@@ -145,9 +145,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}, []); }, []);
useEffect(() => { useEffect(() => {
const nextSection = String(resolvedInitialSection || 'dashboard'); function onCalendarOpenTask(event) {
setActiveSection(nextSection); const task = event?.detail?.task;
}, [resolvedInitialSection]); if (task) setSelectedTask(task);
}
window.addEventListener('calendar:open-task', onCalendarOpenTask);
return () => window.removeEventListener('calendar:open-task', onCalendarOpenTask);
}, []);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -578,6 +582,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const taskColumns = [ const taskColumns = [
{ key: 'title', label: 'Radni zadatak', className: 'px-4 py-3' }, { key: 'title', label: 'Radni zadatak', className: 'px-4 py-3' },
{ key: 'status', label: 'Status', className: 'px-4 py-3' }, { key: 'status', label: 'Status', className: 'px-4 py-3' },
{ key: 'scheduledDate', label: 'Planirano', className: 'px-4 py-3' },
{ key: 'createdAt', label: 'Vrijeme izrade', className: 'px-4 py-3' }, { key: 'createdAt', label: 'Vrijeme izrade', className: 'px-4 py-3' },
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' }, { key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'workOrder', label: 'Putni nalog', className: 'px-4 py-3' }, { key: 'workOrder', label: 'Putni nalog', className: 'px-4 py-3' },
@@ -750,6 +755,15 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
{getStatusLabel(task.status || '-')} {getStatusLabel(task.status || '-')}
</span> </span>
</td> </td>
<td className="px-4 py-3">
{task.scheduled_date ? (
<span className="font-medium text-indigo-700">
{formatDate(task.scheduled_date)}
</span>
) : (
<span className="text-text-muted">-</span>
)}
</td>
<td <td
className="px-4 py-3" className="px-4 py-3"
title={formatDateTime(task.created_at) !== '-' ? formatDateTime(task.created_at) : undefined} title={formatDateTime(task.created_at) !== '-' ? formatDateTime(task.created_at) : undefined}

View File

@@ -0,0 +1,276 @@
import { useMemo, useState } from 'preact/hooks';
import { animated, useSpring } from '@react-spring/web';
import { getStatusLabel } from '../../stores/taskStore';
const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Če', 'Pe', 'Su', 'Ne'];
const MONTH_NAMES = [
'Siječanj', 'Veljača', 'Ožujak', 'Travanj', 'Svibanj', 'Lipanj',
'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac',
];
const PANEL_WIDTH = 300;
function toIsoDateKey(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function formatDayLabel(year, month, day) {
return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`;
}
export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
const [selectedDay, setSelectedDay] = useState(null);
const panelSpring = useSpring({
left: open ? 0 : -PANEL_WIDTH,
config: { tension: 260, friction: 28 },
});
const buttonSpring = useSpring({
left: open ? PANEL_WIDTH : 0,
config: { tension: 260, friction: 28 },
});
// Map: "YYYY-MM-DD" -> task[]
const tasksByDate = useMemo(() => {
const map = {};
for (const task of tasks) {
const key = toIsoDateKey(task.scheduled_date);
if (!key) continue;
if (!map[key]) map[key] = [];
map[key].push(task);
}
return map;
}, [tasks]);
// Calendar grid cells for current month
const calendarDays = useMemo(() => {
const firstDay = new Date(viewYear, viewMonth, 1);
let startDow = firstDay.getDay(); // 0=Sun
startDow = (startDow + 6) % 7; // shift to Mon=0
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
return cells;
}, [viewYear, viewMonth]);
const selectedDayKey = selectedDay
? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}`
: null;
const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : [];
const today = new Date();
function prevMonth() {
setSelectedDay(null);
if (viewMonth === 0) { setViewYear((y) => y - 1); setViewMonth(11); }
else setViewMonth((m) => m - 1);
}
function nextMonth() {
setSelectedDay(null);
if (viewMonth === 11) { setViewYear((y) => y + 1); setViewMonth(0); }
else setViewMonth((m) => m + 1);
}
function handleDayClick(day) {
setSelectedDay((prev) => (prev === day ? null : day));
}
function handleTaskClick(task) {
onTaskClick?.(task);
setOpen(false);
}
const totalScheduled = Object.values(tasksByDate).flat().length;
return (
<>
{/* Backdrop */}
{open && (
<div
role="presentation"
className="fixed inset-0 z-[45] bg-black/20 backdrop-blur-[1px]"
onClick={() => setOpen(false)}
/>
)}
{/* Clipping wrapper — animates width 0→300, clips shadow+border completely */}
<animated.div
style={{
width: panelSpring.left.to((v) => `${Math.max(0, v + PANEL_WIDTH)}px`),
pointerEvents: open ? 'auto' : 'none',
}}
className="fixed left-0 top-0 z-[46] h-screen overflow-hidden"
>
{/* Inner panel — always 300px, anchored to right edge of wrapper */}
<div
style={{ width: `${PANEL_WIDTH}px` }}
className="absolute right-0 top-0 flex h-full flex-col border-r border-border-hairline bg-canvas-elevated shadow-2xl"
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h2 className="text-sm font-semibold text-text-main">📅 Kalendar zadataka</h2>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md p-1 text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori kalendar"
>
</button>
</div>
{/* Month navigation */}
<div className="flex items-center justify-between px-4 py-2">
<button
type="button"
onClick={prevMonth}
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
aria-label="Prethodni mjesec"
>
</button>
<span className="text-sm font-medium text-text-main">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
<button
type="button"
onClick={nextMonth}
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
aria-label="Sljedeći mjesec"
>
</button>
</div>
{/* Calendar grid */}
<div className="px-3">
<div className="grid grid-cols-7 text-center">
{WEEKDAY_LABELS.map((label) => (
<div key={label} className="py-1 text-[11px] font-medium text-text-muted">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-y-0.5 text-center">
{calendarDays.map((day, idx) => {
if (!day) return <div key={`e-${idx}`} />;
const key = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const hasTasks = Boolean(tasksByDate[key]?.length);
const taskCount = tasksByDate[key]?.length ?? 0;
const isToday =
today.getFullYear() === viewYear &&
today.getMonth() === viewMonth &&
today.getDate() === day;
const isSelected = selectedDay === day;
return (
<button
key={key}
type="button"
onClick={() => handleDayClick(day)}
title={hasTasks ? `${taskCount} zadatak/a` : undefined}
className={[
'relative mx-auto flex h-8 w-8 items-center justify-center rounded-full text-xs transition-colors',
isSelected
? 'bg-indigo-600 font-semibold text-white'
: isToday
? 'bg-indigo-50 font-semibold text-indigo-700'
: hasTasks
? 'font-medium text-text-main hover:bg-canvas-deep'
: 'text-text-main hover:bg-canvas-deep',
].join(' ')}
>
{day}
{hasTasks && (
<span
className={[
'absolute bottom-0.5 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full',
isSelected ? 'bg-white' : 'bg-indigo-500',
].join(' ')}
/>
)}
</button>
);
})}
</div>
</div>
{/* Divider */}
<div className="mt-2 border-t border-border-hairline" />
{/* Task list for selected day OR hint */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{selectedDay ? (
<>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{formatDayLabel(viewYear, viewMonth, selectedDay)}
</p>
{selectedDayTasks.length === 0 ? (
<p className="text-sm text-text-muted">Nema zadataka za odabrani datum.</p>
) : (
<ul className="space-y-1.5">
{selectedDayTasks.map((task) => (
<li key={task.id}>
<button
type="button"
onClick={() => handleTaskClick(task)}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-left text-sm hover:bg-indigo-50 hover:border-indigo-200 transition-colors"
>
<span className="block truncate font-medium text-text-main">
{task.title || '-'}
</span>
<span className="mt-0.5 block text-[11px] text-text-muted">
{task.assigned_to_name || '-'} {' '}
{getStatusLabel(task.status || '-')}
</span>
</button>
</li>
))}
</ul>
)}
</>
) : (
<>
<p className="text-xs text-text-muted">
Odaberite datum u kalendaru za prikaz zadataka.
</p>
{totalScheduled > 0 && (
<p className="mt-1.5 text-xs font-medium text-indigo-600">
{totalScheduled} zadatak/a s planiranim datumom.
</p>
)}
{totalScheduled === 0 && (
<p className="mt-1.5 text-[11px] text-text-muted">
Nijedan zadatak nema postavljen planirani datum.
</p>
)}
</>
)}
</div>
</div>
</animated.div>
{/* Toggle tab button — slides right as panel opens */}
<animated.button
type="button"
style={{ left: buttonSpring.left.to((v) => `${v}px`) }}
onClick={() => setOpen((o) => !o)}
className="fixed top-1/2 z-[47] -translate-y-1/2 rounded-r-xl border border-l-0 border-border-hairline bg-canvas-elevated px-1.5 py-4 shadow-md hover:bg-indigo-50 focus:outline-none"
aria-label={open ? 'Zatvori kalendar' : 'Otvori kalendar'}
title="Kalendar zadataka"
>
<span
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed' }}
className="select-none text-[11px] font-medium text-text-main"
>
📅 Kalendar
</span>
</animated.button>
</>
);
}

View File

@@ -10,6 +10,7 @@ const INITIAL_FORM = {
description: '', description: '',
status: 'aktivan', status: 'aktivan',
work_order: '', work_order: '',
scheduled_date: '',
}; };
export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) { export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
@@ -50,6 +51,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
description: selectedTemplate.description || '', description: selectedTemplate.description || '',
status: 'aktivan', status: 'aktivan',
work_order: '', work_order: '',
// scheduled_date is kept so user can set it in template mode too
})); }));
}, [selectedTemplate]); }, [selectedTemplate]);
@@ -98,6 +100,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
template_id: templateId, template_id: templateId,
vehicle: contextCrane.id, vehicle: contextCrane.id,
work_order: null, work_order: null,
scheduled_date: form.scheduled_date || null,
}; };
} else { } else {
const title = String(form.title || '').trim(); const title = String(form.title || '').trim();
@@ -115,6 +118,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
status: form.status || 'aktivan', status: form.status || 'aktivan',
vehicle: contextCrane.id, vehicle: contextCrane.id,
work_order: form.work_order || null, work_order: form.work_order || null,
scheduled_date: form.scheduled_date || null,
}; };
} }
@@ -231,6 +235,17 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
</label> </label>
<label className="flex flex-col gap-1 text-sm"> <label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Planirani datum izvršenja</span>
<input
type="date"
value={form.scheduled_date}
onInput={(event) => setField('scheduled_date', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="font-medium text-text-main">Povezani putni nalog</span> <span className="font-medium text-text-main">Povezani putni nalog</span>
<select <select
value={form.work_order} value={form.work_order}

View File

@@ -47,6 +47,7 @@ export default function TaskServiceRecordsModal({
title: '', title: '',
description: '', description: '',
status: 'aktivan', status: 'aktivan',
scheduled_date: '',
}); });
useEffect(() => { useEffect(() => {
@@ -67,6 +68,7 @@ export default function TaskServiceRecordsModal({
title: String(task.title || ''), title: String(task.title || ''),
description: String(task.description || ''), description: String(task.description || ''),
status: String(task.status || 'aktivan'), status: String(task.status || 'aktivan'),
scheduled_date: String(task.scheduled_date || ''),
}); });
}, [task?.id, task?.work_order]); }, [task?.id, task?.work_order]);
@@ -117,7 +119,8 @@ export default function TaskServiceRecordsModal({
title, title,
description, description,
status, status,
}); scheduled_date: taskForm.scheduled_date || null,
});
setShowTaskEditor(false); setShowTaskEditor(false);
} catch (error) { } catch (error) {
const conflictCode = error?.details?.code; const conflictCode = error?.details?.code;
@@ -130,6 +133,7 @@ export default function TaskServiceRecordsModal({
title, title,
description, description,
status, status,
scheduled_date: taskForm.scheduled_date || null,
auto_close_work_order: true, auto_close_work_order: true,
}); });
setShowTaskEditor(false); setShowTaskEditor(false);
@@ -215,6 +219,10 @@ export default function TaskServiceRecordsModal({
<dt className="text-xs text-text-muted">Putni nalog</dt> <dt className="text-xs text-text-muted">Putni nalog</dt>
<dd className="text-text-main">{task.work_order_label || '-'}</dd> <dd className="text-text-main">{task.work_order_label || '-'}</dd>
</div> </div>
<div>
<dt className="text-xs text-text-muted">Planirani datum</dt>
<dd className="text-text-main">{task.scheduled_date ? formatDate(task.scheduled_date) : '-'}</dd>
</div>
<div> <div>
<dt className="text-xs text-text-muted">Vrijeme izrade</dt> <dt className="text-xs text-text-muted">Vrijeme izrade</dt>
<dd className="text-text-main">{formatDateTime(task.created_at)}</dd> <dd className="text-text-main">{formatDateTime(task.created_at)}</dd>
@@ -262,6 +270,16 @@ export default function TaskServiceRecordsModal({
))} ))}
</select> </select>
</label> </label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Planirani datum izvršenja</span>
<input
type="date"
value={taskForm.scheduled_date || ''}
onInput={(event) => setTaskForm((prev) => ({ ...prev, scheduled_date: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
{taskError ? ( {taskError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"> <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{taskError} {taskError}
@@ -277,6 +295,7 @@ export default function TaskServiceRecordsModal({
title: String(task.title || ''), title: String(task.title || ''),
description: String(task.description || ''), description: String(task.description || ''),
status: String(task.status || 'aktivan'), status: String(task.status || 'aktivan'),
scheduled_date: String(task.scheduled_date || ''),
}); });
}} }}
disabled={updatingTask} disabled={updatingTask}

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { $tasks } from '../../stores/taskStore';
import { $user } from '../../stores/authStore';
import TaskCalendarWidget from '../dashboard/TaskCalendarWidget';
/**
* Mountan u Layout.astro (van AnimatedPage transforma) pa fixed pozicija
* uvijek ostaje uz viewport rub.
*
* Filtriranje taskova po ulozi:
* - Serviser → samo taskovi dodijeljeni njemu
* - Supervisor (nije serviser) → svi taskovi
*
* Kad korisnik klikne task u kalendaru, šaljemo custom event koji
* FleetDashboardShell sluša i otvara TaskServiceRecordsModal.
*/
export default function TaskCalendarPortal() {
const allTasks = useStore($tasks);
const user = useStore($user);
const isServiser = Boolean(user?.is_serviser);
const isTeamMember = Boolean(user?.is_team_member);
const isSupervisorNotServiser = isTeamMember && !isServiser;
const tasks = useMemo(() => {
if (!user?.id) return [];
// Supervisor vidi sve; serviser vidi samo svoje
if (isSupervisorNotServiser) return allTasks;
return allTasks.filter(
(t) => String(t.assigned_to || '') === String(user.id)
);
}, [allTasks, user, isSupervisorNotServiser]);
// Ne renderiramo ništa ako korisnik nije prijavljen
if (!user?.id) return null;
function handleTaskClick(task) {
window.dispatchEvent(
new CustomEvent('calendar:open-task', { detail: { task } })
);
}
return (
<TaskCalendarWidget
tasks={tasks}
onTaskClick={handleTaskClick}
/>
);
}

View File

@@ -11,10 +11,12 @@ export default function ThemeToggle() {
const toggleTheme = () => { const toggleTheme = () => {
if (document.documentElement.classList.contains('dark')) { if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
document.documentElement.style.backgroundColor = '#ffffff';
localStorage.setItem('theme', 'light'); localStorage.setItem('theme', 'light');
setIsDark(false); setIsDark(false);
} else { } else {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
document.documentElement.style.backgroundColor = '#1f2329';
localStorage.setItem('theme', 'dark'); localStorage.setItem('theme', 'dark');
setIsDark(true); setIsDark(true);
} }

View File

@@ -8,6 +8,7 @@ import Toast from '../components/ui/Toast.jsx';
import ToastTrigger from '../components/ToastTrigger.jsx'; import ToastTrigger from '../components/ToastTrigger.jsx';
import NetworkGuard from '../components/ui/NetworkGuard.jsx'; import NetworkGuard from '../components/ui/NetworkGuard.jsx';
import Navbar from '../components/Navbar.jsx'; import Navbar from '../components/Navbar.jsx';
import TaskCalendarPortal from '../components/layout/TaskCalendarPortal.jsx';
interface Props { interface Props {
title: string; title: string;
@@ -39,8 +40,11 @@ const isDev = import.meta.env.DEV;
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches; const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) { if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
// Postavi pozadinu direktno na html elementu kako bi bila dostupna i prije učitavanja CSS-a
document.documentElement.style.backgroundColor = '#1f2329';
} else { } else {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
document.documentElement.style.backgroundColor = '#ffffff';
} }
} }
primijeniTemu(); primijeniTemu();
@@ -48,13 +52,14 @@ const isDev = import.meta.env.DEV;
</script> </script>
</head> </head>
<body class="relative min-h-screen bg-canvas-base text-text-main font-sans antialiased transition-colors duration-300"> <body class="relative min-h-screen bg-canvas-base text-text-main font-sans antialiased">
<div class="absolute inset-0 bg-[linear-gradient(to_right,var(--color-text-main)_0.03_1px,transparent_1px),linear-gradient(to_bottom,var(--color-text-main)_0.03_1px,transparent_1px)] bg-[size:64px_64px] opacity-[0.4] dark:opacity-[0.02] pointer-events-none z-0"></div> <div class="absolute inset-0 bg-[linear-gradient(to_right,var(--color-text-main)_0.03_1px,transparent_1px),linear-gradient(to_bottom,var(--color-text-main)_0.03_1px,transparent_1px)] bg-[size:64px_64px] opacity-[0.4] dark:opacity-[0.02] pointer-events-none z-0"></div>
{!isAuthPage && <NetworkGuard client:only="preact" />} {!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
<Toast client:only="preact" /> <Toast client:only="preact" transition:persist="toast" />
{!isAuthPage && isDev && <ToastTrigger client:load />} {!isAuthPage && isDev && <ToastTrigger client:load />}
{!isAuthPage && <Navbar client:load minimal={minimalNav} />} {!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
{!isAuthPage && <TaskCalendarPortal client:only="preact" transition:persist="calendar-portal" />}
<div id="main-content" class="relative z-10 flex flex-col min-h-screen max-w-6xl mx-auto px-6 md:px-8 py-6"> <div id="main-content" class="relative z-10 flex flex-col min-h-screen max-w-6xl mx-auto px-6 md:px-8 py-6">
<header class="flex flex-row justify-between items-end pb-6 mb-8 border-b border-border-hairline"> <header class="flex flex-row justify-between items-end pb-6 mb-8 border-b border-border-hairline">
@@ -66,7 +71,7 @@ const isDev = import.meta.env.DEV;
</div> </div>
</header> </header>
<main class="flex-1 w-full" transition:animate="fade"> <main class="flex-1 w-full">
<slot /> <slot />
</main> </main>

View File

@@ -8,12 +8,25 @@ export const $clients = atom([]);
export const $clientsLoading = atom(false); export const $clientsLoading = atom(false);
export const $clientsError = atom(null); export const $clientsError = atom(null);
export async function fetchClients(signal) { let _lastClientsFetchAt = 0;
const CLIENTS_FETCH_TTL_MS = 30_000; // 30 sekundi
export async function fetchClients(arg) {
const options = (arg && typeof arg === 'object' && !('aborted' in arg))
? arg
: { signal: arg };
const { signal, force = false } = options;
if (!force && $clients.get().length > 0 && Date.now() - _lastClientsFetchAt < CLIENTS_FETCH_TTL_MS) {
return;
}
$clientsLoading.set(true); $clientsLoading.set(true);
$clientsError.set(null); $clientsError.set(null);
try { try {
const data = await api.get('crm/clients/', { signal }); const data = await api.get('crm/clients/', { signal });
$clients.set(Array.isArray(data) ? data : (data.results ?? [])); $clients.set(Array.isArray(data) ? data : (data.results ?? []));
_lastClientsFetchAt = Date.now();
} catch (err) { } catch (err) {
if (err?.name !== 'AbortError') $clientsError.set(err.message ?? 'Greška'); if (err?.name !== 'AbortError') $clientsError.set(err.message ?? 'Greška');
} finally { } finally {
@@ -24,16 +37,19 @@ export async function fetchClients(signal) {
export async function createClient(payload) { export async function createClient(payload) {
const data = await api.post('crm/clients/', payload); const data = await api.post('crm/clients/', payload);
$clients.set([...$clients.get(), data]); $clients.set([...$clients.get(), data]);
_lastClientsFetchAt = Date.now();
return data; return data;
} }
export async function updateClient(id, payload) { export async function updateClient(id, payload) {
const data = await api.patch(`crm/clients/${id}/`, payload); const data = await api.patch(`crm/clients/${id}/`, payload);
$clients.set($clients.get().map((c) => (c.id === id ? data : c))); $clients.set($clients.get().map((c) => (c.id === id ? data : c)));
_lastClientsFetchAt = Date.now();
return data; return data;
} }
export async function deleteClient(id) { export async function deleteClient(id) {
await api.delete(`crm/clients/${id}/`); await api.delete(`crm/clients/${id}/`);
$clients.set($clients.get().filter((c) => c.id !== id)); $clients.set($clients.get().filter((c) => c.id !== id));
_lastClientsFetchAt = Date.now();
} }

View File

@@ -2,6 +2,7 @@ import { atom, computed } from 'nanostores';
import { api } from '../services/apiClient'; import { api } from '../services/apiClient';
import { $accessToken } from './authStore'; import { $accessToken } from './authStore';
import { showToast } from './toastStore'; import { showToast } from './toastStore';
import { $tasks } from './taskStore';
const DB_NAME = 'erp-offline-db'; const DB_NAME = 'erp-offline-db';
const DB_VERSION = 1; const DB_VERSION = 1;
@@ -20,6 +21,10 @@ export const $serviceRecords = atom([]);
export const $offlineQueueCount = atom(0); export const $offlineQueueCount = atom(0);
export const $syncLog = atom([]); export const $syncLog = atom([]);
// Timestamp posljednjeg uspješnog fetch-a — koristi se za TTL provjeru
let _lastDashboardFetchAt = 0;
const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi
let dbPromise = null; let dbPromise = null;
let syncListenerStarted = false; let syncListenerStarted = false;
let isSyncInProgress = false; let isSyncInProgress = false;
@@ -440,12 +445,15 @@ export const $workOrdersWithVehicle = computed(
); );
export const $dashboardStats = computed( export const $dashboardStats = computed(
[$workOrders, $serviceRecords], [$workOrders, $serviceRecords, $tasks],
(workOrders, serviceRecords) => { (workOrders, serviceRecords, tasks) => {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const openWorkOrders = workOrders.filter((workOrder) => workOrder.status !== 'closed').length; const openWorkOrders = workOrders.filter((workOrder) => workOrder.status !== 'closed').length;
const closedWorkOrders = workOrders.filter((workOrder) => workOrder.status === 'closed').length; const closedWorkOrders = workOrders.filter((workOrder) => workOrder.status === 'closed').length;
const servicesToday = serviceRecords.filter((record) => record.service_date === today).length; // Broji Taskove čiji je scheduled_date danas i status aktivan ili u tijeku
const servicesToday = tasks.filter(
(t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis')
).length;
const warnings = serviceRecords.filter((record) => { const warnings = serviceRecords.filter((record) => {
if (record.next_service_due_at == null || record.mileage == null) return false; if (record.next_service_due_at == null || record.mileage == null) return false;
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000; return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
@@ -484,7 +492,13 @@ export async function ensureVehiclesCatalog() {
} }
export async function fetchFleetDashboardData(options = {}) { export async function fetchFleetDashboardData(options = {}) {
const { silent = false } = options; const { silent = false, force = false } = options;
// Preskoči fetch ako su podaci svježi (unutar TTL), osim ako je force=true
if (!force && $workOrders.get().length > 0 && Date.now() - _lastDashboardFetchAt < DASHBOARD_FETCH_TTL_MS) {
return;
}
startOfflineSyncListeners(); startOfflineSyncListeners();
if (!silent) $dashboardLoading.set(true); if (!silent) $dashboardLoading.set(true);
$dashboardError.set(null); $dashboardError.set(null);
@@ -507,6 +521,7 @@ export async function fetchFleetDashboardData(options = {}) {
$workOrders.set(Array.isArray(workOrders) ? workOrders : []); $workOrders.set(Array.isArray(workOrders) ? workOrders : []);
$serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []); $serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
$vehicles.set(normalizeCraneList(vehicles)); $vehicles.set(normalizeCraneList(vehicles));
_lastDashboardFetchAt = Date.now();
await persistDashboardCache(); await persistDashboardCache();
await processOfflineQueue(); await processOfflineQueue();
} catch (error) { } catch (error) {
@@ -809,11 +824,18 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') { export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
if (!downloadUrl) { if (!downloadUrl) {
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');
throw new Error('Nedostaje URL za preuzimanje PDF-a.'); throw new Error('Nedostaje URL za preuzimanje PDF-a.');
} }
const blob = await api.get(downloadUrl, { responseType: 'blob' }); try {
saveBlobToFile(blob, filename); const blob = await api.get(downloadUrl, { responseType: 'blob' });
return true; saveBlobToFile(blob, filename);
return true;
} catch (err) {
const msg = err?.message || 'Preuzimanje PDF-a nije uspjelo.';
showToast(msg, 'error');
throw err;
}
} }
export function openWorkOrderInvoicesPdfPage(workOrderId) { export function openWorkOrderInvoicesPdfPage(workOrderId) {

View File

@@ -4,14 +4,14 @@ export const $isOffline = atom(false);
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/'; const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
// Heartbeat endpoint: OPTIONS /api/token/ // Heartbeat endpoint: GET /api/health/
// DRF odgovara bez 405 greške i dovoljno je za provjeru dostupnosti backenda. // Time izbjegavamo periodično gađanje auth/token endpointa.
const HEARTBEAT_URL = new URL('token/', API_BASE).toString(); const HEARTBEAT_URL = new URL('health/', API_BASE).toString();
async function provjeriStvarnuVezu() { async function provjeriStvarnuVezu() {
try { try {
const response = await fetch(HEARTBEAT_URL, { const response = await fetch(HEARTBEAT_URL, {
method: 'OPTIONS', method: 'GET',
cache: 'no-store', cache: 'no-store',
}); });

View File

@@ -8,6 +8,9 @@ export const $tasksLoading = atom(false);
export const $taskTemplates = atom([]); export const $taskTemplates = atom([]);
export const $taskTemplatesLoading = atom(false); export const $taskTemplatesLoading = atom(false);
let _lastTaskFetchAt = 0;
const TASK_FETCH_TTL_MS = 30_000; // 30 sekundi
const STATUS_LABELS = { const STATUS_LABELS = {
aktivan: 'Aktivan', aktivan: 'Aktivan',
servis: 'U tijeku', servis: 'U tijeku',
@@ -25,11 +28,15 @@ export function isTaskActive(task) {
return status === 'aktivan' || status === 'servis' || status === 'spreman_za_zavrsetak'; return status === 'aktivan' || status === 'servis' || status === 'spreman_za_zavrsetak';
} }
export async function fetchTasks() { export async function fetchTasks({ force = false } = {}) {
if (!force && $tasks.get().length > 0 && Date.now() - _lastTaskFetchAt < TASK_FETCH_TTL_MS) {
return;
}
$tasksLoading.set(true); $tasksLoading.set(true);
try { try {
const data = await api.get('tasks/tasks/'); const data = await api.get('tasks/tasks/');
$tasks.set(Array.isArray(data) ? data : (data?.results ?? [])); $tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
_lastTaskFetchAt = Date.now();
} catch (error) { } catch (error) {
if (error?.status !== 401) { if (error?.status !== 401) {
showToast('Greška pri dohvatu zadataka.', 'error'); showToast('Greška pri dohvatu zadataka.', 'error');

View File

@@ -59,3 +59,16 @@
body.modal-open #main-content { body.modal-open #main-content {
filter: blur(4px); filter: blur(4px);
} }
/* View transitions — stara stranica ostaje vidljiva dok nova ne fade-ina, bez bijelog flash-a */
::view-transition-old(root) {
animation: none;
}
::view-transition-new(root) {
animation: 150ms ease-out fade-in-page forwards;
}
@keyframes fade-in-page {
from { opacity: 0; }
to { opacity: 1; }
}