feat: dodaj bilješke widget i admin kalendar/notifikacije
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

Uveden je novi Notes drawer s kalendar integracijom, zajednički DRY
left-slide mehanizam te međusobno isključivo prikazivanje s Kalendar
widgetom.

Backend i frontend su prošireni za servisne bilješke (uključujući slanje
na više servisera), dodan je users/servicers endpoint, te su uvedene
warning notifikacije kada serviser ima više od 2 zadatka isti dan.

U admin su dodane stavke za Bilješke i zaseban Kalendar zadataka (proxy
TaskCalendar) radi lakšeg operativnog pregleda.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-20 06:33:36 +02:00
parent 61996bd1a0
commit 1a18498b30
20 changed files with 1312 additions and 113 deletions

View File

@@ -59,3 +59,28 @@ class TestUserAPI:
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert self.user.first_name == "Inženjer" assert self.user.first_name == "Inženjer"
def test_get_servicers_returns_only_servicers(self):
serviser = User.objects.create_user(
email="serviser@test.hr",
first_name="Serviser",
last_name="Jedan",
password="strongpassword123",
is_serviser=True,
is_team_member=True,
)
non_serviser = User.objects.create_user(
email="nons@test.hr",
first_name="Nije",
last_name="Serviser",
password="strongpassword123",
is_serviser=False,
is_team_member=True,
)
self.client.force_authenticate(user=self.user)
url = reverse('user-servicers')
response = self.client.get(url)
assert response.status_code == status.HTTP_200_OK
returned_ids = {str(item['id']) for item in response.data}
assert str(serviser.id) in returned_ids
assert str(non_serviser.id) not in returned_ids

View File

@@ -30,6 +30,12 @@ class UserViewSet(viewsets.ModelViewSet):
serializer = TeamMemberSerializer(queryset, many=True) serializer = TeamMemberSerializer(queryset, many=True)
return Response(serializer.data) return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='servicers')
def servicers(self, request):
queryset = User.objects.filter(is_active=True, is_serviser=True).order_by('first_name', 'last_name', 'email')
serializer = TeamMemberSerializer(queryset, many=True)
return Response(serializer.data)
def perform_update(self, serializer): def perform_update(self, serializer):
# Delegiramo logiku servisu # Delegiramo logiku servisu
UserService.update_user_profile( UserService.update_user_profile(

View File

@@ -8,6 +8,7 @@ from .models import (
WorkOrderPhoto, WorkOrderPhoto,
WorkOrderInvoice, WorkOrderInvoice,
WorkOrderAdditionalCostsTable, WorkOrderAdditionalCostsTable,
ServiceContextNote,
GeneratedWorkOrderPdf, GeneratedWorkOrderPdf,
VehicleServiceRecord, VehicleServiceRecord,
VehicleServicePhoto, VehicleServicePhoto,
@@ -205,6 +206,15 @@ class WorkOrderAdditionalCostsTableAdmin(admin.ModelAdmin):
ordering = ('-updated_at',) ordering = ('-updated_at',)
@admin.register(ServiceContextNote)
class ServiceContextNoteAdmin(admin.ModelAdmin):
list_display = ('id', 'recipient', 'created_by', 'audience_source', 'note_date', 'is_closed', 'created_at')
list_filter = ('audience_source', 'is_closed', 'note_date')
search_fields = ('note', 'recipient__email', 'recipient__first_name', 'recipient__last_name', 'created_by__email')
readonly_fields = ('created_at', 'updated_at', 'closed_at')
ordering = ('is_closed', 'note_date', '-created_at')
@admin.register(GeneratedWorkOrderPdf) @admin.register(GeneratedWorkOrderPdf)
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin): class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at') list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.15 on 2026-07-20 03:05
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('task_management', '0010_alter_tasktemplate_status'),
('fleet', '0031_alter_workorderadditionalcoststable_created_at_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ServiceContextNote',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('audience_source', models.CharField(choices=[('self', 'Samo autor'), ('member', 'Odabrani član tima'), ('team', 'Svi članovi tima')], default='self', max_length=16, verbose_name='Publika')),
('note', models.TextField(verbose_name='Bilješka')),
('note_date', models.DateField(blank=True, db_index=True, null=True, verbose_name='Datum podsjetnika')),
('is_closed', models.BooleanField(default=False, verbose_name='Ručno zatvoreno')),
('closed_at', models.DateTimeField(blank=True, null=True, verbose_name='Vrijeme zatvaranja')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_service_context_notes', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_context_notes', to=settings.AUTH_USER_MODEL, verbose_name='Primatelj')),
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_context_notes', to='task_management.task', verbose_name='Radni zadatak')),
('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_context_notes', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Bilješka servisnog konteksta',
'verbose_name_plural': 'Bilješke servisnog konteksta',
'ordering': ['is_closed', 'note_date', '-created_at'],
},
),
]

View File

@@ -566,3 +566,71 @@ class VehicleNotification(BaseModel):
def __str__(self): def __str__(self):
return f"{self.title} -> {self.recipient} ({self.level})" return f"{self.title} -> {self.recipient} ({self.level})"
class ServiceContextNote(BaseModel):
AUDIENCE_SOURCE_CHOICES = [
('self', 'Samo autor'),
('member', 'Odabrani član tima'),
('team', 'Svi članovi tima'),
]
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='created_service_context_notes',
verbose_name=_("Kreirao"),
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='service_context_notes',
verbose_name=_("Primatelj"),
)
audience_source = models.CharField(
max_length=16,
choices=AUDIENCE_SOURCE_CHOICES,
default='self',
verbose_name=_("Publika"),
)
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='service_context_notes',
verbose_name=_("Putni nalog"),
)
task = models.ForeignKey(
'task_management.Task',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='service_context_notes',
verbose_name=_("Radni zadatak"),
)
note = models.TextField(verbose_name=_("Bilješka"))
note_date = models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name=_("Datum podsjetnika"),
)
is_closed = models.BooleanField(default=False, verbose_name=_("Ručno zatvoreno"))
closed_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Vrijeme zatvaranja"))
class Meta:
verbose_name = _("Bilješka servisnog konteksta")
verbose_name_plural = _("Bilješke servisnog konteksta")
ordering = ['is_closed', 'note_date', '-created_at']
def __str__(self):
return f"Note {self.pk} -> {self.recipient_id}"
def clean(self):
super().clean()
if self.task_id and self.work_order_id and self.task and self.task.work_order_id:
if self.task.work_order_id != self.work_order_id:
raise ValidationError({
'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."),
})

View File

@@ -2,11 +2,14 @@ from rest_framework import serializers
import re import re
from pathlib import Path from pathlib import Path
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.contrib.auth import get_user_model
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from modules.task_management.models import Task
from .models import ( from .models import (
Crane, Crane,
Vehicle, Vehicle,
ServiceContextNote,
VehicleNotification, VehicleNotification,
WorkOrder, WorkOrder,
WorkOrderAdditionalCostsTable, WorkOrderAdditionalCostsTable,
@@ -18,6 +21,8 @@ from .models import (
) )
from . import services from . import services
User = get_user_model()
class VehicleSerializer(serializers.ModelSerializer): class VehicleSerializer(serializers.ModelSerializer):
# Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu # Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
@@ -95,6 +100,134 @@ class VehicleNotificationSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at'] read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at']
# is_read is writable via the "mark_read" action, but normally read-only in list # is_read is writable via the "mark_read" action, but normally read-only in list
class ServiceContextNoteSerializer(serializers.ModelSerializer):
created_by_name = serializers.SerializerMethodField()
recipient_name = serializers.SerializerMethodField()
work_order_label = serializers.SerializerMethodField()
task_title = serializers.SerializerMethodField()
class Meta:
model = ServiceContextNote
fields = [
'id',
'created_by',
'created_by_name',
'recipient',
'recipient_name',
'audience_source',
'work_order',
'work_order_label',
'task',
'task_title',
'note',
'note_date',
'is_closed',
'closed_at',
'created_at',
'updated_at',
]
read_only_fields = fields
def get_created_by_name(self, obj):
return obj.created_by.get_full_name() or obj.created_by.email
def get_recipient_name(self, obj):
return obj.recipient.get_full_name() or obj.recipient.email
def get_work_order_label(self, obj):
if not obj.work_order_id:
return None
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
return display_code or f"WO-{obj.work_order_id}"
def get_task_title(self, obj):
if not obj.task_id:
return None
return obj.task.title
class ServiceContextNoteCreateSerializer(serializers.Serializer):
AUDIENCE_CHOICES = ['self', 'member', 'team']
note = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
note_date = serializers.DateField(required=False, allow_null=True)
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
target_user = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(is_active=True),
required=False,
allow_null=True,
)
target_users = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True)),
required=False,
allow_empty=False,
)
work_order = serializers.PrimaryKeyRelatedField(
queryset=WorkOrder.objects.filter(is_active=True),
required=False,
allow_null=True,
)
task = serializers.PrimaryKeyRelatedField(
queryset=Task.objects.filter(is_active=True),
required=False,
allow_null=True,
)
def validate(self, attrs):
request = self.context.get('request')
user = getattr(request, 'user', None)
if user is None:
raise serializers.ValidationError('Autentikacija je obavezna.')
audience = attrs.get('audience', 'self')
target_user = attrs.get('target_user')
target_users = attrs.get('target_users') or []
work_order = attrs.get('work_order')
task = attrs.get('task')
note = str(attrs.get('note', '') or '').strip()
can_manage_recipients = bool(
getattr(user, 'is_serviser', False)
or getattr(user, 'is_staff', False)
or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False))
)
if not note:
raise serializers.ValidationError({'note': 'Bilješka ne može biti prazna.'})
if not can_manage_recipients and audience != 'self':
raise serializers.ValidationError({'audience': 'Samo supervisor ili serviser može slati bilješke drugim članovima tima.'})
if audience == 'member' and not target_user and len(target_users) == 0:
raise serializers.ValidationError({'target_users': 'Odaberite barem jednog servisera.'})
if audience in {'self', 'team'} and target_user:
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
if audience in {'self', 'team'} and len(target_users) > 0:
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu servisera.'})
if target_user and not getattr(target_user, 'is_serviser', False):
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo serviseru.'})
for selected_user in target_users:
if not getattr(selected_user, 'is_serviser', False):
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo serviserima.'})
if work_order:
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
raise serializers.ValidationError({'work_order': 'Moguće je povezati samo aktivni otvoreni putni nalog.'})
if task:
status = str(task.status or '').lower()
if status in {'zavrsen', 'neaktivan'}:
raise serializers.ValidationError({'task': 'Moguće je povezati samo aktivni radni zadatak.'})
if task and work_order and task.work_order_id and str(task.work_order_id) != str(work_order.id):
raise serializers.ValidationError({'task': 'Radni zadatak ne pripada odabranom putnom nalogu.'})
attrs['note'] = note
if task and not work_order and task.work_order_id:
attrs['work_order'] = task.work_order
if audience == 'member' and len(target_users) == 0 and target_user:
attrs['target_users'] = [target_user]
return attrs
class WorkOrderSerializer(serializers.ModelSerializer): class WorkOrderSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField() crane = serializers.SerializerMethodField()
crane_label = serializers.SerializerMethodField() crane_label = serializers.SerializerMethodField()

View File

@@ -0,0 +1,134 @@
import uuid
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder
from modules.task_management.models import Task
class ServiceContextNotesApiTests(TestCase):
def setUp(self):
user_model = get_user_model()
suffix = uuid.uuid4().hex[:8]
self.supervisor = user_model.objects.create_user(
username=f'supervisor-{suffix}',
email=f'supervisor-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=False,
)
self.servicer = user_model.objects.create_user(
username=f'servicer-{suffix}',
email=f'servicer-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=True,
)
self.other = user_model.objects.create_user(
username=f'other-{suffix}',
email=f'other-{suffix}@example.test',
password='test1234',
is_team_member=False,
is_serviser=False,
)
self.second_servicer = user_model.objects.create_user(
username=f'servicer2-{suffix}',
email=f'servicer2-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=True,
)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number=f'SN-{suffix[:6].upper()}',
crane_serial_number='654321',
assigned_servicer=self.servicer,
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.supervisor,
purpose='kontrola',
status='open',
)
self.task = Task.objects.create(
title='Aktivni servis',
assigned_to=self.servicer,
vehicle=self.vehicle,
work_order=self.work_order,
status='aktivan',
)
self.client = APIClient()
def test_supervisor_can_create_team_note_for_active_context(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Provjeriti opremu prije izlaska na teren.',
'note_date': '2026-07-21',
'audience': 'team',
'work_order': str(self.work_order.id),
'task': str(self.task.id),
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertGreaterEqual(int(response.data.get('created_count', 0)), 2)
self.client.force_authenticate(user=self.servicer)
list_response = self.client.get(reverse('service-context-note-list'))
self.assertEqual(list_response.status_code, 200, list_response.content)
self.assertEqual(len(list_response.data), 1)
self.assertEqual(list_response.data[0]['audience_source'], 'team')
self.assertEqual(str(list_response.data[0]['task']), str(self.task.id))
def test_servicer_can_create_member_note_for_other_servicer(self):
self.client.force_authenticate(user=self.servicer)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Serviser šalje bilješku drugom serviseru.',
'audience': 'member',
'target_user': str(self.second_servicer.id),
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(int(response.data.get('created_count', 0)), 1)
def test_recipient_can_close_personal_note(self):
note = ServiceContextNote.objects.create(
created_by=self.supervisor,
recipient=self.servicer,
audience_source='member',
note='Osobna bilješka',
)
self.client.force_authenticate(user=self.servicer)
close_url = reverse('service-context-note-close', kwargs={'pk': note.id})
close_response = self.client.post(close_url, data={}, format='json')
self.assertEqual(close_response.status_code, 200, close_response.content)
note.refresh_from_db()
self.assertTrue(note.is_closed)
list_response = self.client.get(reverse('service-context-note-list'))
self.assertEqual(list_response.status_code, 200)
self.assertEqual(len(list_response.data), 0)
def test_supervisor_can_target_multiple_servicers(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Zajednička bilješka za dva servisera.',
'audience': 'member',
'target_users': [str(self.servicer.id), str(self.second_servicer.id)],
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(int(response.data.get('created_count', 0)), 2)

View File

@@ -2,7 +2,7 @@ from rest_framework.routers import DefaultRouter
from django.urls import path from django.urls import path
from .views import ( from .views import (
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
WorkOrderInvoiceViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
) )
router = DefaultRouter() router = DefaultRouter()
@@ -10,6 +10,7 @@ router.register(r'vehicles', VehicleViewSet, basename='vehicle')
router.register(r'cranes', CraneViewSet, basename='crane') router.register(r'cranes', CraneViewSet, basename='crane')
router.register(r'work-orders', WorkOrderViewSet, basename='workorder') router.register(r'work-orders', WorkOrderViewSet, basename='workorder')
router.register(r'work-order-invoices', WorkOrderInvoiceViewSet, basename='work-order-invoice') router.register(r'work-order-invoices', WorkOrderInvoiceViewSet, basename='work-order-invoice')
router.register(r'service-notes', ServiceContextNoteViewSet, basename='service-context-note')
router.register(r'service-records', VehicleServiceRecordViewSet, basename='service-record') router.register(r'service-records', VehicleServiceRecordViewSet, basename='service-record')
router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification') router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo') router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')

View File

@@ -2,9 +2,11 @@
from io import BytesIO from io import BytesIO
import base64 import base64
from collections import OrderedDict
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.contrib.auth import get_user_model
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from django.conf import settings from django.conf import settings
@@ -14,6 +16,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.html import escape from django.utils.html import escape
from django.db.models import Q from django.db.models import Q
from django.db import transaction
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
@@ -21,7 +24,7 @@ from reportlab.lib.styles import getSampleStyleSheet
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.platypus import Table, TableStyle, Paragraph from reportlab.platypus import Table, TableStyle, Paragraph
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
from rest_framework import viewsets, permissions, status from rest_framework import viewsets, permissions, status, mixins
from rest_framework.decorators import action, api_view, permission_classes from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError
@@ -32,6 +35,7 @@ from .models import (
Crane, Crane,
Vehicle, Vehicle,
VehicleNotification, VehicleNotification,
ServiceContextNote,
WorkOrder, WorkOrder,
WorkOrderPhoto, WorkOrderPhoto,
GeneratedWorkOrderPdf, GeneratedWorkOrderPdf,
@@ -50,6 +54,8 @@ from .serializers import (
WorkOrderPhotoSerializer, WorkOrderPhotoSerializer,
VehicleServiceRecordSerializer, VehicleServiceRecordSerializer,
VehicleNotificationSerializer, VehicleNotificationSerializer,
ServiceContextNoteSerializer,
ServiceContextNoteCreateSerializer,
VehicleServicePhotoSerializer, VehicleServicePhotoSerializer,
VehicleServiceAttachmentSerializer, VehicleServiceAttachmentSerializer,
) )
@@ -67,6 +73,7 @@ from .tasks import (
) )
register_unicode_fonts() register_unicode_fonts()
User = get_user_model()
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
@@ -99,6 +106,23 @@ def _work_orders_queryset_for_user(user):
return queryset.filter(creator=user) return queryset.filter(creator=user)
def _is_supervisor_user(user):
return bool(getattr(user, 'is_staff', False) or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False)))
def _active_team_members_queryset():
return User.objects.filter(is_active=True, is_team_member=True).order_by('first_name', 'last_name', 'email')
def _service_context_notes_queryset_for_user(user):
return (
ServiceContextNote.objects
.select_related('created_by', 'recipient', 'work_order', 'task')
.filter(is_active=True, recipient=user, is_closed=False)
.order_by('note_date', '-created_at')
)
def _work_order_related_tasks_queryset(work_order): def _work_order_related_tasks_queryset(work_order):
from modules.task_management.models import Task from modules.task_management.models import Task
@@ -1994,6 +2018,110 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
instance.is_active = False instance.is_active = False
instance.save(update_fields=['is_active']) instance.save(update_fields=['is_active'])
class ServiceContextNoteViewSet(
mixins.ListModelMixin,
mixins.CreateModelMixin,
viewsets.GenericViewSet,
):
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return _service_context_notes_queryset_for_user(self.request.user)
def get_serializer_class(self):
if self.action == 'create':
return ServiceContextNoteCreateSerializer
return ServiceContextNoteSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data
user = request.user
audience = payload.get('audience', 'self')
target_users = payload.get('target_users') or []
note_text = payload.get('note')
note_date = payload.get('note_date')
work_order = payload.get('work_order')
task = payload.get('task')
is_supervisor = _is_supervisor_user(user)
can_manage_recipients = bool(is_supervisor or getattr(user, 'is_serviser', False))
if work_order and not _work_orders_queryset_for_user(user).filter(pk=work_order.pk).exists():
raise DRFValidationError({'work_order': 'Nemate dozvolu za odabrani putni nalog.'})
if task and not is_supervisor and str(getattr(task, 'assigned_to_id', '') or '') != str(user.id):
raise DRFValidationError({'task': 'Možete povezati samo svoj aktivni radni zadatak.'})
recipients = []
if audience == 'self':
recipients = [user]
elif audience == 'member':
recipients = list(target_users)
else:
if not can_manage_recipients:
raise DRFValidationError({'audience': 'Nemate dozvolu za slanje bilješke cijelom timu.'})
recipients = list(_active_team_members_queryset())
if not recipients:
raise DRFValidationError({'audience': 'Nema aktivnih članova tima za slanje bilješke.'})
created_notes = []
seen = OrderedDict()
for recipient in recipients:
seen[str(recipient.id)] = recipient
with transaction.atomic():
for recipient in seen.values():
note = ServiceContextNote.objects.create(
created_by=user,
recipient=recipient,
audience_source=audience,
work_order=work_order,
task=task,
note=note_text,
note_date=note_date,
)
created_notes.append(note)
NotificationService.create_notification(
recipient=recipient,
title='Nova bilješka servisnog konteksta',
message=note_text[:255],
level='info',
metadata={
'entity_type': 'service_note',
'service_note_id': str(note.id),
'work_order_id': str(work_order.id) if work_order else None,
'task_id': str(task.id) if task else None,
'section': 'dashboard',
'note_date': note_date.isoformat() if note_date else None,
},
send_email=False,
)
response_serializer = ServiceContextNoteSerializer(
_service_context_notes_queryset_for_user(user)[:20],
many=True,
)
return Response(
{
'created_count': len(created_notes),
'notes': response_serializer.data,
},
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=['post'], url_path='close')
def close(self, request, pk=None):
note = self.get_object()
if note.is_closed:
serializer = ServiceContextNoteSerializer(note)
return Response(serializer.data, status=status.HTTP_200_OK)
note.is_closed = True
note.closed_at = timezone.now()
note.save(update_fields=['is_closed', 'closed_at', 'updated_at'])
serializer = ServiceContextNoteSerializer(note)
return Response(serializer.data, status=status.HTTP_200_OK)
class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet): class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet):
""" """
List all notifications for the authenticated user and provide an action List all notifications for the authenticated user and provide an action

View File

@@ -2,7 +2,7 @@
from django.contrib import admin from django.contrib import admin
from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable from .models import Task, TaskCalendar, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
class TaskWorkEntryInline(admin.TabularInline): class TaskWorkEntryInline(admin.TabularInline):
@@ -33,6 +33,19 @@ class TaskAdmin(admin.ModelAdmin):
inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline) inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline)
@admin.register(TaskCalendar)
class TaskCalendarAdmin(admin.ModelAdmin):
list_display = ('scheduled_date', 'title', 'status', 'assigned_to', 'vehicle', 'work_order')
list_filter = ('scheduled_date', 'status', 'assigned_to')
search_fields = ('title', 'assigned_to__email', 'vehicle__registration_number', 'work_order__display_code')
readonly_fields = ('id', 'created_at', 'updated_at')
ordering = ('scheduled_date', 'assigned_to__first_name', 'assigned_to__last_name')
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.filter(is_active=True, scheduled_date__isnull=False)
@admin.register(TaskTemplate) @admin.register(TaskTemplate)
class TaskTemplateAdmin(admin.ModelAdmin): class TaskTemplateAdmin(admin.ModelAdmin):
list_display = ('code', 'title', 'status', 'is_active', 'created_at') list_display = ('code', 'title', 'status', 'is_active', 'created_at')

View File

@@ -69,6 +69,13 @@ class Task(BaseModel):
}) })
class TaskCalendar(Task):
class Meta:
proxy = True
verbose_name = _("Kalendar zadatka")
verbose_name_plural = _("Kalendar zadataka")
class TaskWorkHoursTable(BaseModel): class TaskWorkHoursTable(BaseModel):
"""Interaktivna tablica radnih sati povezana s radnim zadatkom.""" """Interaktivna tablica radnih sati povezana s radnim zadatkom."""

View File

@@ -1,8 +1,9 @@
# backend/modules/task_management/services.py # backend/modules/task_management/services.py
from django.db import transaction from django.db import transaction, models
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.contrib.auth import get_user_model
import logging import logging
from .models import Task, TaskTemplate, TaskWorkEntry from .models import Task, TaskTemplate, TaskWorkEntry
@@ -10,6 +11,21 @@ from .exceptions import OpenWorkOrderBlockingTaskClose
from modules.fleet.models import VehicleServiceRecord from modules.fleet.models import VehicleServiceRecord
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model()
def _task_overload_recipients(task):
recipients = []
if task.assigned_to_id:
recipients.append(task.assigned_to)
supervisors = User.objects.filter(is_active=True).filter(
models.Q(is_staff=True) | (models.Q(is_team_member=True) & models.Q(is_serviser=False))
)
recipients.extend(supervisors)
unique = {}
for recipient in recipients:
unique[str(recipient.id)] = recipient
return list(unique.values())
class TaskService: class TaskService:
@@ -53,6 +69,7 @@ class TaskService:
with transaction.atomic(): with transaction.atomic():
entry.save() entry.save()
TaskService._notify_if_servicer_overloaded(entry)
if template: if template:
TaskService._create_work_entries_from_template(task=entry, template=template) TaskService._create_work_entries_from_template(task=entry, template=template)
TaskService._create_service_records_from_template(task=entry, template=template, user=user) TaskService._create_service_records_from_template(task=entry, template=template, user=user)
@@ -60,6 +77,49 @@ class TaskService:
logger.info(f"Zadatak uspješno kreiran: {entry.id}") logger.info(f"Zadatak uspješno kreiran: {entry.id}")
return entry return entry
@staticmethod
def _notify_if_servicer_overloaded(entry: Task):
if not entry.assigned_to_id or not entry.scheduled_date:
return
if not getattr(entry.assigned_to, 'is_serviser', False):
return
task_count = Task.objects.filter(
is_active=True,
assigned_to=entry.assigned_to,
scheduled_date=entry.scheduled_date,
status__in=('aktivan', 'servis', 'spreman_za_zavrsetak'),
).count()
if task_count <= 2:
return
from modules.fleet.services import NotificationService
schedule_label = entry.scheduled_date.strftime('%d.%m.%Y')
title = f"Preopterećenje rasporeda: {entry.assigned_to.get_full_name() or entry.assigned_to.email}"
message = (
f"Serviseru {entry.assigned_to.get_full_name() or entry.assigned_to.email} "
f"je dodijeljeno {task_count} zadatka za {schedule_label}. "
f"Sustav je dopustio i dodatni zadatak."
)
metadata = {
"entity_type": "task_overload",
"assigned_to_id": str(entry.assigned_to_id),
"task_id": str(entry.id),
"scheduled_date": entry.scheduled_date.isoformat(),
"task_count": task_count,
"section": "dashboard",
}
for recipient in _task_overload_recipients(entry):
NotificationService.create_notification(
recipient=recipient,
title=title,
message=message,
level='warning',
send_email=False,
metadata=metadata,
)
@staticmethod @staticmethod
def _create_work_entries_from_template(*, task: Task, template: TaskTemplate): def _create_work_entries_from_template(*, task: Task, template: TaskTemplate):
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10]) template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])

View File

@@ -1,5 +1,7 @@
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
from unittest.mock import patch
from datetime import date
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from modules.task_management.services import TaskService from modules.task_management.services import TaskService
from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose
@@ -193,3 +195,75 @@ class TaskServiceTests(TestCase):
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 10) self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 10)
self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10) self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_create_task_entry_sends_overload_notifications_after_two_tasks(self, mock_notify):
servicer = get_user_model().objects.create_user(
username='servicer-overload',
email='servicer-overload@example.test',
password='test1234',
is_serviser=True,
is_team_member=True,
)
supervisor = get_user_model().objects.create_user(
username='supervisor-overload',
email='supervisor-overload@example.test',
password='test1234',
is_team_member=True,
is_serviser=False,
)
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-OVER1", asset_type='crane')
scheduled = date(2026, 7, 21)
Task.objects.create(title="Postojeći 1", assigned_to=servicer, vehicle=vehicle, status='aktivan', scheduled_date=scheduled)
Task.objects.create(title="Postojeći 2", assigned_to=servicer, vehicle=vehicle, status='servis', scheduled_date=scheduled)
TaskService.create_task_entry(
data={
"title": "Treći task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
recipients = [call.kwargs.get('recipient') for call in mock_notify.call_args_list]
recipient_ids = {str(recipient.id) for recipient in recipients if recipient}
self.assertIn(str(servicer.id), recipient_ids)
self.assertIn(str(supervisor.id), recipient_ids)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_create_task_entry_does_not_send_overload_notifications_when_two_or_less(self, mock_notify):
servicer = get_user_model().objects.create_user(
username='servicer-normal-load',
email='servicer-normal-load@example.test',
password='test1234',
is_serviser=True,
is_team_member=True,
)
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-OVER2", asset_type='crane')
scheduled = date(2026, 7, 21)
TaskService.create_task_entry(
data={
"title": "Prvi task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
TaskService.create_task_entry(
data={
"title": "Drugi task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
self.assertEqual(mock_notify.call_count, 0)

View File

@@ -1,14 +1,12 @@
import { useMemo, useState } from 'preact/hooks'; import { useMemo, useState } from 'preact/hooks';
import { animated, useSpring } from '@react-spring/web';
import { getStatusLabel } from '../../stores/taskStore'; import { getStatusLabel } from '../../stores/taskStore';
import LeftSlideDrawer from '../layout/LeftSlideDrawer';
const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Če', 'Pe', 'Su', 'Ne']; const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Če', 'Pe', 'Su', 'Ne'];
const MONTH_NAMES = [ const MONTH_NAMES = [
'Siječanj', 'Veljača', 'Ožujak', 'Travanj', 'Svibanj', 'Lipanj', 'Siječanj', 'Veljača', 'Ožujak', 'Travanj', 'Svibanj', 'Lipanj',
'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac', 'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac',
]; ];
const PANEL_WIDTH = 300;
function toIsoDateKey(dateStr) { function toIsoDateKey(dateStr) {
if (!dateStr) return null; if (!dateStr) return null;
const d = new Date(dateStr); const d = new Date(dateStr);
@@ -20,22 +18,11 @@ function formatDayLabel(year, month, day) {
return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`; return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`;
} }
export default function TaskCalendarWidget({ tasks = [], onTaskClick }) { export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick }) {
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(() => new Date().getFullYear()); const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth()); const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
const [selectedDay, setSelectedDay] = useState(null); 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[] // Map: "YYYY-MM-DD" -> task[]
const tasksByDate = useMemo(() => { const tasksByDate = useMemo(() => {
const map = {}; const map = {};
@@ -48,6 +35,17 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
return map; return map;
}, [tasks]); }, [tasks]);
const notesByDate = useMemo(() => {
const map = {};
for (const note of notes) {
const key = toIsoDateKey(note.note_date);
if (!key) continue;
if (!map[key]) map[key] = [];
map[key].push(note);
}
return map;
}, [notes]);
// Calendar grid cells for current month // Calendar grid cells for current month
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
const firstDay = new Date(viewYear, viewMonth, 1); const firstDay = new Date(viewYear, viewMonth, 1);
@@ -64,6 +62,7 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}` ? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}`
: null; : null;
const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : []; const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : [];
const selectedDayNotes = selectedDayKey ? (notesByDate[selectedDayKey] ?? []) : [];
const today = new Date(); const today = new Date();
function prevMonth() { function prevMonth() {
@@ -81,49 +80,27 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
setSelectedDay((prev) => (prev === day ? null : day)); setSelectedDay((prev) => (prev === day ? null : day));
} }
function handleTaskClick(task) { function handleTaskClick(task, closePanel) {
onTaskClick?.(task); onTaskClick?.(task);
setOpen(false); closePanel?.();
} }
const totalScheduled = Object.values(tasksByDate).flat().length; const totalScheduled = Object.values(tasksByDate).flat().length;
const totalNoteReminders = Object.values(notesByDate).flat().length;
return ( return (
<> <LeftSlideDrawer
{/* Backdrop */} drawerId="calendar"
{open && ( title="📅 Kalendar zadataka"
<div tabLabel="📅 Kalendar"
role="presentation" tabTitle="Kalendar zadataka"
className="fixed inset-0 z-[45] bg-black/20 backdrop-blur-[1px]" tabTopClassName="top-1/2"
onClick={() => setOpen(false)} backdropClassName="z-[45]"
/> panelClassName="z-[46]"
)} tabClassName="z-[47]"
>
{/* Clipping wrapper — animates width 0→300, clips shadow+border completely */} {({ close }) => (
<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 */} {/* Month navigation */}
<div className="flex items-center justify-between px-4 py-2"> <div className="flex items-center justify-between px-4 py-2">
@@ -162,7 +139,9 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
if (!day) return <div key={`e-${idx}`} />; if (!day) return <div key={`e-${idx}`} />;
const key = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const key = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const hasTasks = Boolean(tasksByDate[key]?.length); const hasTasks = Boolean(tasksByDate[key]?.length);
const hasNotes = Boolean(notesByDate[key]?.length);
const taskCount = tasksByDate[key]?.length ?? 0; const taskCount = tasksByDate[key]?.length ?? 0;
const noteCount = notesByDate[key]?.length ?? 0;
const isToday = const isToday =
today.getFullYear() === viewYear && today.getFullYear() === viewYear &&
today.getMonth() === viewMonth && today.getMonth() === viewMonth &&
@@ -173,7 +152,11 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
key={key} key={key}
type="button" type="button"
onClick={() => handleDayClick(day)} onClick={() => handleDayClick(day)}
title={hasTasks ? `${taskCount} zadatak/a` : undefined} title={
hasTasks || hasNotes
? `${taskCount} zadatak/a, ${noteCount} bilješki`
: undefined
}
className={[ className={[
'relative mx-auto flex h-8 w-8 items-center justify-center rounded-full text-xs transition-colors', 'relative mx-auto flex h-8 w-8 items-center justify-center rounded-full text-xs transition-colors',
isSelected isSelected
@@ -186,13 +169,25 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
].join(' ')} ].join(' ')}
> >
{day} {day}
{hasTasks && ( {(hasTasks || hasNotes) && (
<span <span className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 items-center gap-0.5">
className={[ {hasTasks && (
'absolute bottom-0.5 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full', <span
isSelected ? 'bg-white' : 'bg-indigo-500', className={[
].join(' ')} 'h-1.5 w-1.5 rounded-full',
/> isSelected ? 'bg-white' : 'bg-indigo-500',
].join(' ')}
/>
)}
{hasNotes && (
<span
className={[
'h-1.5 w-1.5 rounded-full',
isSelected ? 'bg-violet-200' : 'bg-violet-500',
].join(' ')}
/>
)}
</span>
)} )}
</button> </button>
); );
@@ -210,28 +205,50 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-text-muted"> <p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{formatDayLabel(viewYear, viewMonth, selectedDay)} {formatDayLabel(viewYear, viewMonth, selectedDay)}
</p> </p>
{selectedDayTasks.length === 0 ? ( {selectedDayTasks.length === 0 && selectedDayNotes.length === 0 ? (
<p className="text-sm text-text-muted">Nema zadataka za odabrani datum.</p> <p className="text-sm text-text-muted">Nema događaja za odabrani datum.</p>
) : ( ) : (
<ul className="space-y-1.5"> <div className="space-y-3">
{selectedDayTasks.map((task) => ( {selectedDayNotes.length > 0 && (
<li key={task.id}> <div>
<button <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700">Bilješke</p>
type="button" <ul className="space-y-1.5">
onClick={() => handleTaskClick(task)} {selectedDayNotes.map((note) => (
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" <li key={note.id} className="rounded-lg border border-violet-200 bg-violet-50 px-3 py-2 text-sm text-violet-900">
> <span className="block">{note.note}</span>
<span className="block truncate font-medium text-text-main"> <span className="mt-0.5 block text-[11px] text-violet-700">
{task.title || '-'} {note.task_title || note.work_order_label || 'Servisni kontekst'}
</span> </span>
<span className="mt-0.5 block text-[11px] text-text-muted"> </li>
{task.assigned_to_name || '-'} {' '} ))}
{getStatusLabel(task.status || '-')} </ul>
</span> </div>
</button> )}
</li> {selectedDayTasks.length > 0 && (
))} <div>
</ul> <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-indigo-700">Zadaci</p>
<ul className="space-y-1.5">
{selectedDayTasks.map((task) => (
<li key={task.id}>
<button
type="button"
onClick={() => handleTaskClick(task, close)}
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>
</div>
)}
</div>
)} )}
</> </>
) : ( ) : (
@@ -244,6 +261,11 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
{totalScheduled} zadatak/a s planiranim datumom. {totalScheduled} zadatak/a s planiranim datumom.
</p> </p>
)} )}
{totalNoteReminders > 0 && (
<p className="mt-1 text-xs font-medium text-violet-600">
{totalNoteReminders} bilješki/podsjetnika u kalendaru.
</p>
)}
{totalScheduled === 0 && ( {totalScheduled === 0 && (
<p className="mt-1.5 text-[11px] text-text-muted"> <p className="mt-1.5 text-[11px] text-text-muted">
Nijedan zadatak nema postavljen planirani datum. Nijedan zadatak nema postavljen planirani datum.
@@ -252,25 +274,8 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
</> </>
)} )}
</div> </div>
</div> </>
</animated.div> )}
</LeftSlideDrawer>
{/* 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

@@ -0,0 +1,110 @@
import { useEffect } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { animated, useSpring } from '@react-spring/web';
import { $activeLeftDrawer, closeLeftDrawer, openLeftDrawer } from '../../stores/leftDrawerStore';
const DEFAULT_PANEL_WIDTH = 300;
export default function LeftSlideDrawer({
drawerId,
title,
tabLabel,
tabTitle,
panelWidth = DEFAULT_PANEL_WIDTH,
tabTopClassName = 'top-1/2',
backdropClassName = 'z-[45]',
panelClassName = 'z-[46]',
tabClassName = 'z-[47]',
children,
}) {
const activeDrawer = useStore($activeLeftDrawer);
const isOpen = String(activeDrawer || '') === String(drawerId || '');
const isHiddenByOtherDrawer = Boolean(activeDrawer) && !isOpen;
const panelSpring = useSpring({
left: isOpen ? 0 : -panelWidth,
config: { tension: 260, friction: 28 },
});
const buttonSpring = useSpring({
left: isOpen ? panelWidth : 0,
config: { tension: 260, friction: 28 },
});
useEffect(() => {
return () => {
closeLeftDrawer(drawerId);
};
}, [drawerId]);
if (!drawerId) {
return null;
}
if (isHiddenByOtherDrawer) {
return null;
}
function handleToggle() {
if (isOpen) {
closeLeftDrawer(drawerId);
return;
}
openLeftDrawer(drawerId);
}
return (
<>
{isOpen && (
<div
role="presentation"
className={`fixed inset-0 bg-black/20 backdrop-blur-[1px] ${backdropClassName}`}
onClick={() => closeLeftDrawer(drawerId)}
/>
)}
<animated.div
style={{
width: panelSpring.left.to((value) => `${Math.max(0, value + panelWidth)}px`),
pointerEvents: isOpen ? 'auto' : 'none',
}}
className={`fixed left-0 top-0 h-screen overflow-hidden ${panelClassName}`}
>
<div
style={{ width: `${panelWidth}px` }}
className="absolute right-0 top-0 flex h-full flex-col border-r border-border-hairline bg-canvas-elevated shadow-2xl"
>
<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">{title}</h2>
<button
type="button"
onClick={() => closeLeftDrawer(drawerId)}
className="rounded-md p-1 text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori panel"
>
</button>
</div>
{typeof children === 'function'
? children({ open: isOpen, close: () => closeLeftDrawer(drawerId) })
: children}
</div>
</animated.div>
<animated.button
type="button"
style={{ left: buttonSpring.left.to((value) => `${value}px`) }}
onClick={handleToggle}
className={`fixed ${tabTopClassName} -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 ${tabClassName}`}
aria-label={isOpen ? `Zatvori ${tabTitle || 'panel'}` : `Otvori ${tabTitle || 'panel'}`}
title={tabTitle || tabLabel}
>
<span
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed' }}
className="select-none text-[11px] font-medium text-text-main"
>
{tabLabel}
</span>
</animated.button>
</>
);
}

View File

@@ -0,0 +1,248 @@
import { useMemo, useState } from 'preact/hooks';
import LeftSlideDrawer from './LeftSlideDrawer';
function formatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('hr-HR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
function workOrderLabel(item) {
const code = String(item?.display_code || '').trim().toUpperCase();
if (code) return code;
return `WO-${String(item?.id || '').slice(0, 8)}`;
}
export default function ServiceNotesWidget({
notes = [],
loading = false,
canManageRecipients = false,
activeTasks = [],
activeWorkOrders = [],
servicers = [],
onCreate,
onClose,
}) {
const [note, setNote] = useState('');
const [noteDate, setNoteDate] = useState('');
const [audience, setAudience] = useState('self');
const [targetUsers, setTargetUsers] = useState([]);
const [contextType, setContextType] = useState('none');
const [contextValue, setContextValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const undatedNotes = useMemo(
() => notes.filter((item) => !item.note_date),
[notes]
);
const datedNotes = useMemo(
() => notes.filter((item) => !!item.note_date),
[notes]
);
async function handleSubmit(event) {
event.preventDefault();
if (!String(note || '').trim()) return;
const payload = {
note: String(note || '').trim(),
note_date: noteDate || null,
audience,
};
if (audience === 'member' && targetUsers.length > 0) {
payload.target_users = targetUsers;
}
if (contextType === 'work_order' && contextValue) {
payload.work_order = contextValue;
}
if (contextType === 'task' && contextValue) {
payload.task = contextValue;
}
setSubmitting(true);
try {
await onCreate?.(payload);
setNote('');
setNoteDate('');
setContextType('none');
setContextValue('');
setAudience('self');
setTargetUsers([]);
} finally {
setSubmitting(false);
}
}
return (
<LeftSlideDrawer
drawerId="notes"
title="🗒️ Bilješke"
tabLabel="🗒️ Bilješke"
tabTitle="Bilješke"
tabTopClassName="top-[calc(50%+96px)]"
backdropClassName="z-[48]"
panelClassName="z-[49]"
tabClassName="z-[50]"
>
<p className="border-b border-border-hairline px-4 py-2 text-[11px] text-text-muted">
Bilješke i podsjetnici u servisnom kontekstu
</p>
<form onSubmit={handleSubmit} className="space-y-2 border-b border-border-hairline px-4 py-3">
<textarea
value={note}
onInput={(event) => setNote(event.currentTarget.value)}
className="h-16 w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
placeholder="Upiši bilješku..."
required
/>
<input
type="date"
value={noteDate}
onInput={(event) => setNoteDate(event.currentTarget.value)}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
/>
<select
value={contextType}
onChange={(event) => {
setContextType(event.currentTarget.value);
setContextValue('');
}}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
<option value="none">Bez vezanog naloga/zadatka</option>
<option value="work_order">Aktivni putni nalog</option>
<option value="task">Aktivni radni zadatak</option>
</select>
{contextType === 'work_order' && (
<select
value={contextValue}
onChange={(event) => setContextValue(event.currentTarget.value)}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
<option value="">Odaberi putni nalog</option>
{activeWorkOrders.map((item) => (
<option key={item.id} value={item.id}>
{workOrderLabel(item)}
</option>
))}
</select>
)}
{contextType === 'task' && (
<select
value={contextValue}
onChange={(event) => setContextValue(event.currentTarget.value)}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
<option value="">Odaberi radni zadatak</option>
{activeTasks.map((item) => (
<option key={item.id} value={item.id}>
{item.title || `Task ${String(item.id).slice(0, 8)}`}
</option>
))}
</select>
)}
<select
value={audience}
onChange={(event) => {
setAudience(event.currentTarget.value);
if (event.currentTarget.value !== 'member') {
setTargetUsers([]);
}
}}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
<option value="self">Notifikacija samo meni</option>
{canManageRecipients && <option value="member">Notifikacija serviserima</option>}
{canManageRecipients && <option value="team">Notifikacija svim članovima tima</option>}
</select>
{canManageRecipients && audience === 'member' && (
<select
multiple
value={targetUsers}
onChange={(event) => {
const selected = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
setTargetUsers(selected);
}}
size={Math.min(6, Math.max(3, servicers.length || 3))}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
{servicers.map((member) => (
<option key={member.id} value={member.id}>
{member.full_name || member.email}
</option>
))}
</select>
)}
{canManageRecipients && audience === 'member' && (
<p className="text-[10px] text-text-muted">
Držite Ctrl (ili Cmd) za odabir više servisera.
</p>
)}
<button
type="submit"
disabled={submitting}
className="w-full rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremanje...' : 'Spremi bilješku'}
</button>
</form>
<div className="flex-1 overflow-y-auto px-4 py-3">
{loading && <p className="text-xs text-text-muted">Učitavanje bilješki...</p>}
{!loading && undatedNotes.length === 0 && datedNotes.length === 0 && (
<p className="text-xs text-text-muted">Nema aktivnih bilješki.</p>
)}
{undatedNotes.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700">Bez datuma (permanentno)</p>
<ul className="space-y-1.5">
{undatedNotes.map((item) => (
<li key={item.id} className="rounded-md border border-violet-200 bg-violet-50 px-2 py-1.5">
<p className="text-xs text-violet-900">{item.note}</p>
<div className="mt-1 flex items-center justify-between">
<span className="text-[10px] text-violet-700">{item.task_title || item.work_order_label || 'Općenito'}</span>
<button
type="button"
onClick={() => onClose?.(item.id)}
className="text-[10px] font-semibold text-violet-700 hover:text-violet-900"
>
Zatvori
</button>
</div>
</li>
))}
</ul>
</div>
)}
{datedNotes.length > 0 && (
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-text-muted">Podsjetnici</p>
<ul className="space-y-1.5">
{datedNotes.map((item) => (
<li key={item.id} className="rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5">
<p className="text-xs text-text-main">{item.note}</p>
<div className="mt-1 flex items-center justify-between text-[10px] text-text-muted">
<span>{formatDate(item.note_date)}</span>
<button
type="button"
onClick={() => onClose?.(item.id)}
className="font-semibold text-indigo-600 hover:text-indigo-800"
>
Zatvori
</button>
</div>
</li>
))}
</ul>
</div>
)}
</div>
</LeftSlideDrawer>
);
}

View File

@@ -1,8 +1,17 @@
import { useMemo } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact'; import { useStore } from '@nanostores/preact';
import { $tasks } from '../../stores/taskStore'; import { $tasks } from '../../stores/taskStore';
import { $user } from '../../stores/authStore'; import { $user } from '../../stores/authStore';
import { $workOrdersWithVehicle, fetchServicers } from '../../stores/fleetDashboardStore';
import {
$serviceNotes,
$serviceNotesLoading,
closeServiceNote,
createServiceNote,
fetchServiceNotes,
} from '../../stores/serviceNotesStore';
import TaskCalendarWidget from '../dashboard/TaskCalendarWidget'; import TaskCalendarWidget from '../dashboard/TaskCalendarWidget';
import ServiceNotesWidget from './ServiceNotesWidget';
/** /**
* Mountan u Layout.astro (van AnimatedPage transforma) pa fixed pozicija * Mountan u Layout.astro (van AnimatedPage transforma) pa fixed pozicija
@@ -17,11 +26,16 @@ import TaskCalendarWidget from '../dashboard/TaskCalendarWidget';
*/ */
export default function TaskCalendarPortal() { export default function TaskCalendarPortal() {
const allTasks = useStore($tasks); const allTasks = useStore($tasks);
const workOrders = useStore($workOrdersWithVehicle);
const notes = useStore($serviceNotes);
const notesLoading = useStore($serviceNotesLoading);
const user = useStore($user); const user = useStore($user);
const [servicers, setServicers] = useState([]);
const isServiser = Boolean(user?.is_serviser); const isServiser = Boolean(user?.is_serviser);
const isTeamMember = Boolean(user?.is_team_member); const isTeamMember = Boolean(user?.is_team_member);
const isSupervisorNotServiser = isTeamMember && !isServiser; const isSupervisorNotServiser = isTeamMember && !isServiser;
const canManageRecipients = isSupervisorNotServiser || isServiser;
const tasks = useMemo(() => { const tasks = useMemo(() => {
if (!user?.id) return []; if (!user?.id) return [];
@@ -32,6 +46,49 @@ export default function TaskCalendarPortal() {
); );
}, [allTasks, user, isSupervisorNotServiser]); }, [allTasks, user, isSupervisorNotServiser]);
const activeTasks = useMemo(
() => tasks.filter((task) => !['zavrsen', 'neaktivan'].includes(String(task.status || '').toLowerCase())),
[tasks]
);
const activeWorkOrders = useMemo(() => {
const base = Array.isArray(workOrders) ? workOrders : [];
if (isSupervisorNotServiser) {
return base.filter((item) => String(item.status || '').toLowerCase() === 'open');
}
return base.filter((item) => (
String(item.status || '').toLowerCase() === 'open'
&& (
String(item.creator || '') === String(user?.id || '')
|| String(item.craneInfo?.assigned_servicer || '') === String(user?.id || '')
)
));
}, [workOrders, isSupervisorNotServiser, user?.id]);
useEffect(() => {
if (!user?.id) return;
fetchServiceNotes().catch(() => {});
}, [user?.id]);
useEffect(() => {
if (!canManageRecipients) {
setServicers([]);
return;
}
let active = true;
fetchServicers()
.then((rows) => {
if (!active) return;
setServicers(Array.isArray(rows) ? rows : []);
})
.catch(() => {
if (!active) return;
setServicers([]);
});
return () => {
active = false;
};
}, [canManageRecipients]);
// Ne renderiramo ništa ako korisnik nije prijavljen // Ne renderiramo ništa ako korisnik nije prijavljen
if (!user?.id) return null; if (!user?.id) return null;
@@ -42,9 +99,22 @@ export default function TaskCalendarPortal() {
} }
return ( return (
<TaskCalendarWidget <>
tasks={tasks} <TaskCalendarWidget
onTaskClick={handleTaskClick} tasks={tasks}
/> notes={notes}
onTaskClick={handleTaskClick}
/>
<ServiceNotesWidget
notes={notes}
loading={notesLoading}
canManageRecipients={canManageRecipients}
activeTasks={activeTasks}
activeWorkOrders={activeWorkOrders}
servicers={servicers}
onCreate={createServiceNote}
onClose={closeServiceNote}
/>
</>
); );
} }

View File

@@ -878,6 +878,11 @@ export async function fetchTeamMembers() {
return Array.isArray(members) ? members : []; return Array.isArray(members) ? members : [];
} }
export async function fetchServicers() {
const members = await api.get('users/servicers/');
return Array.isArray(members) ? members : [];
}
export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {}) { export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {}) {
if (!workOrderId) { if (!workOrderId) {
throw new Error('Work order ID je obavezan.'); throw new Error('Work order ID je obavezan.');

View File

@@ -0,0 +1,17 @@
import { atom } from 'nanostores';
export const $activeLeftDrawer = atom(null);
export function openLeftDrawer(drawerId) {
$activeLeftDrawer.set(drawerId || null);
}
export function closeLeftDrawer(drawerId = null) {
if (!drawerId) {
$activeLeftDrawer.set(null);
return;
}
if (String($activeLeftDrawer.get() || '') === String(drawerId)) {
$activeLeftDrawer.set(null);
}
}

View File

@@ -0,0 +1,44 @@
import { atom } from 'nanostores';
import { api } from '../services/apiClient';
import { showToast } from './toastStore';
export const $serviceNotes = atom([]);
export const $serviceNotesLoading = atom(false);
export async function fetchServiceNotes() {
$serviceNotesLoading.set(true);
try {
const payload = await api.get('fleet/service-notes/');
$serviceNotes.set(Array.isArray(payload) ? payload : []);
} catch (error) {
if (error?.status !== 401) {
showToast('Neuspješno dohvaćanje bilješki.', 'error');
}
throw error;
} finally {
$serviceNotesLoading.set(false);
}
}
export async function createServiceNote(data) {
const payload = await api.post('fleet/service-notes/', data ?? {});
const nextNotes = Array.isArray(payload?.notes) ? payload.notes : null;
if (nextNotes) {
$serviceNotes.set(nextNotes);
} else {
await fetchServiceNotes();
}
showToast('Bilješka je spremljena.', 'success');
return payload;
}
export async function closeServiceNote(noteId) {
if (!noteId) {
throw new Error('ID bilješke je obavezan.');
}
await api.post(`fleet/service-notes/${encodeURIComponent(noteId)}/close/`, {});
$serviceNotes.set(
$serviceNotes.get().filter((item) => String(item.id) !== String(noteId))
);
showToast('Bilješka je zatvorena.', 'success');
}