diff --git a/backend/core/users/tests/test_users.py b/backend/core/users/tests/test_users.py index d84ce7a..39a88a8 100644 --- a/backend/core/users/tests/test_users.py +++ b/backend/core/users/tests/test_users.py @@ -58,4 +58,29 @@ class TestUserAPI: self.user.refresh_from_db() assert response.status_code == status.HTTP_200_OK - assert self.user.first_name == "Inženjer" \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/core/users/views.py b/backend/core/users/views.py index 1cd6468..6498215 100644 --- a/backend/core/users/views.py +++ b/backend/core/users/views.py @@ -30,6 +30,12 @@ class UserViewSet(viewsets.ModelViewSet): serializer = TeamMemberSerializer(queryset, many=True) 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): # Delegiramo logiku servisu UserService.update_user_profile( diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py index 45015db..cdc5972 100644 --- a/backend/modules/fleet/admin.py +++ b/backend/modules/fleet/admin.py @@ -8,6 +8,7 @@ from .models import ( WorkOrderPhoto, WorkOrderInvoice, WorkOrderAdditionalCostsTable, + ServiceContextNote, GeneratedWorkOrderPdf, VehicleServiceRecord, VehicleServicePhoto, @@ -205,6 +206,15 @@ class WorkOrderAdditionalCostsTableAdmin(admin.ModelAdmin): 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) class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin): list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at') diff --git a/backend/modules/fleet/migrations/0032_servicecontextnote.py b/backend/modules/fleet/migrations/0032_servicecontextnote.py new file mode 100644 index 0000000..2816dcd --- /dev/null +++ b/backend/modules/fleet/migrations/0032_servicecontextnote.py @@ -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'], + }, + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index db55c91..a050246 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -565,4 +565,72 @@ class VehicleNotification(BaseModel): ordering = ['-created_at'] def __str__(self): - return f"{self.title} -> {self.recipient} ({self.level})" \ No newline at end of file + 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."), + }) \ No newline at end of file diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py index b8ad785..96a7aac 100644 --- a/backend/modules/fleet/serializers.py +++ b/backend/modules/fleet/serializers.py @@ -2,11 +2,14 @@ from rest_framework import serializers import re from pathlib import Path from decimal import Decimal, InvalidOperation +from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone +from modules.task_management.models import Task from .models import ( Crane, Vehicle, + ServiceContextNote, VehicleNotification, WorkOrder, WorkOrderAdditionalCostsTable, @@ -18,6 +21,8 @@ from .models import ( ) from . import services +User = get_user_model() + class VehicleSerializer(serializers.ModelSerializer): # Č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'] # 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): crane = serializers.SerializerMethodField() crane_label = serializers.SerializerMethodField() diff --git a/backend/modules/fleet/tests/test_service_context_notes.py b/backend/modules/fleet/tests/test_service_context_notes.py new file mode 100644 index 0000000..b8c19f4 --- /dev/null +++ b/backend/modules/fleet/tests/test_service_context_notes.py @@ -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) diff --git a/backend/modules/fleet/urls.py b/backend/modules/fleet/urls.py index dfaefc9..0c91d44 100644 --- a/backend/modules/fleet/urls.py +++ b/backend/modules/fleet/urls.py @@ -2,7 +2,7 @@ from rest_framework.routers import DefaultRouter from django.urls import path from .views import ( CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, - WorkOrderInvoiceViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, + WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, ) router = DefaultRouter() @@ -10,6 +10,7 @@ router.register(r'vehicles', VehicleViewSet, basename='vehicle') router.register(r'cranes', CraneViewSet, basename='crane') router.register(r'work-orders', WorkOrderViewSet, basename='workorder') 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'notifications', VehicleNotificationViewSet, basename='vehicle-notification') router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo') diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 14d7287..99d685e 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -2,9 +2,11 @@ from io import BytesIO import base64 +from collections import OrderedDict from datetime import timedelta from pathlib import Path from decimal import Decimal, InvalidOperation +from django.contrib.auth import get_user_model from PIL import Image, UnidentifiedImageError from django.conf import settings @@ -14,6 +16,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.html import escape from django.db.models import Q +from django.db import transaction from reportlab.lib.pagesizes import A4 from reportlab.lib import colors from reportlab.lib.utils import ImageReader @@ -21,7 +24,7 @@ from reportlab.lib.styles import getSampleStyleSheet from reportlab.pdfgen import canvas from reportlab.platypus import Table, TableStyle, Paragraph 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.response import Response from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError @@ -32,6 +35,7 @@ from .models import ( Crane, Vehicle, VehicleNotification, + ServiceContextNote, WorkOrder, WorkOrderPhoto, GeneratedWorkOrderPdf, @@ -50,6 +54,8 @@ from .serializers import ( WorkOrderPhotoSerializer, VehicleServiceRecordSerializer, VehicleNotificationSerializer, + ServiceContextNoteSerializer, + ServiceContextNoteCreateSerializer, VehicleServicePhotoSerializer, VehicleServiceAttachmentSerializer, ) @@ -67,6 +73,7 @@ from .tasks import ( ) register_unicode_fonts() +User = get_user_model() def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): 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) +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): from modules.task_management.models import Task @@ -1994,6 +2018,110 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet): instance.is_active = False 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): """ List all notifications for the authenticated user and provide an action diff --git a/backend/modules/task_management/admin.py b/backend/modules/task_management/admin.py index b950b2f..5ad7bac 100644 --- a/backend/modules/task_management/admin.py +++ b/backend/modules/task_management/admin.py @@ -2,7 +2,7 @@ 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): @@ -33,6 +33,19 @@ class TaskAdmin(admin.ModelAdmin): 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) class TaskTemplateAdmin(admin.ModelAdmin): list_display = ('code', 'title', 'status', 'is_active', 'created_at') diff --git a/backend/modules/task_management/models.py b/backend/modules/task_management/models.py index de248df..b88cae2 100644 --- a/backend/modules/task_management/models.py +++ b/backend/modules/task_management/models.py @@ -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): """Interaktivna tablica radnih sati povezana s radnim zadatkom.""" diff --git a/backend/modules/task_management/services.py b/backend/modules/task_management/services.py index a776731..b128e86 100644 --- a/backend/modules/task_management/services.py +++ b/backend/modules/task_management/services.py @@ -1,8 +1,9 @@ # backend/modules/task_management/services.py -from django.db import transaction +from django.db import transaction, models from rest_framework.exceptions import ValidationError from django.core.exceptions import ValidationError as DjangoValidationError +from django.contrib.auth import get_user_model import logging from .models import Task, TaskTemplate, TaskWorkEntry @@ -10,6 +11,21 @@ from .exceptions import OpenWorkOrderBlockingTaskClose from modules.fleet.models import VehicleServiceRecord 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: @@ -53,6 +69,7 @@ class TaskService: with transaction.atomic(): entry.save() + TaskService._notify_if_servicer_overloaded(entry) if template: TaskService._create_work_entries_from_template(task=entry, template=template) 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}") 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 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]) diff --git a/backend/modules/task_management/tests/test_services.py b/backend/modules/task_management/tests/test_services.py index 765d99b..9668424 100644 --- a/backend/modules/task_management/tests/test_services.py +++ b/backend/modules/task_management/tests/test_services.py @@ -1,5 +1,7 @@ from django.test import TestCase from django.contrib.auth import get_user_model +from unittest.mock import patch +from datetime import date from rest_framework.exceptions import ValidationError from modules.task_management.services import TaskService from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose @@ -192,4 +194,76 @@ class TaskServiceTests(TestCase): ) self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 10) - self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10) \ No newline at end of file + 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) \ No newline at end of file diff --git a/frontend/src/components/dashboard/TaskCalendarWidget.jsx b/frontend/src/components/dashboard/TaskCalendarWidget.jsx index 0cb3a5c..c67ee89 100644 --- a/frontend/src/components/dashboard/TaskCalendarWidget.jsx +++ b/frontend/src/components/dashboard/TaskCalendarWidget.jsx @@ -1,14 +1,12 @@ import { useMemo, useState } from 'preact/hooks'; -import { animated, useSpring } from '@react-spring/web'; import { getStatusLabel } from '../../stores/taskStore'; +import LeftSlideDrawer from '../layout/LeftSlideDrawer'; 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); @@ -20,22 +18,11 @@ 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); +export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick }) { 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 = {}; @@ -48,6 +35,17 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) { return map; }, [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 const calendarDays = useMemo(() => { 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')}` : null; const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : []; + const selectedDayNotes = selectedDayKey ? (notesByDate[selectedDayKey] ?? []) : []; const today = new Date(); function prevMonth() { @@ -81,49 +80,27 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) { setSelectedDay((prev) => (prev === day ? null : day)); } - function handleTaskClick(task) { + function handleTaskClick(task, closePanel) { onTaskClick?.(task); - setOpen(false); + closePanel?.(); } const totalScheduled = Object.values(tasksByDate).flat().length; + const totalNoteReminders = Object.values(notesByDate).flat().length; return ( - <> - {/* Backdrop */} - {open && ( -
{formatDayLabel(viewYear, viewMonth, selectedDay)}
- {selectedDayTasks.length === 0 ? ( -Nema zadataka za odabrani datum.
+ {selectedDayTasks.length === 0 && selectedDayNotes.length === 0 ? ( +Nema događaja za odabrani datum.
) : ( -Bilješke
+Zadaci
++ {totalNoteReminders} bilješki/podsjetnika u kalendaru. +
+ )} {totalScheduled === 0 && (Nijedan zadatak nema postavljen planirani datum. @@ -252,25 +274,8 @@ export default function TaskCalendarWidget({ tasks = [], onTaskClick }) { > )}
+ Bilješke i podsjetnici u servisnom kontekstu +
+ + + +Učitavanje bilješki...
} + {!loading && undatedNotes.length === 0 && datedNotes.length === 0 && ( +Nema aktivnih bilješki.
+ )} + + {undatedNotes.length > 0 && ( +Bez datuma (permanentno)
+{item.note}
+Podsjetnici
+{item.note}
+