diff --git a/backend/modules/fleet/services.py b/backend/modules/fleet/services.py index dfd32bf..9c6e578 100644 --- a/backend/modules/fleet/services.py +++ b/backend/modules/fleet/services.py @@ -126,6 +126,7 @@ class VehicleService: @staticmethod def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder: + previous_status = instance.status for k, v in data.items(): setattr(instance, k, v) try: @@ -134,8 +135,17 @@ class VehicleService: raise ValidationError({"detail": str(e)}) with transaction.atomic(): instance.save() - # Ako je nalog zatvoren, obavijestimo kreatora/servisera - if instance.status == 'closed': + transitioned_to_closed = previous_status != 'closed' and instance.status == 'closed' + if transitioned_to_closed: + from modules.task_management.models import Task + Task.objects.filter( + is_active=True, + work_order=instance, + status='servis', + ).update(status='spreman_za_zavrsetak') + + # Ako je nalog upravo zatvoren, obavijestimo kreatora/servisera + if transitioned_to_closed: try: NotificationService.create_notification( recipient=instance.creator, diff --git a/backend/modules/fleet/tests/test_services.py b/backend/modules/fleet/tests/test_services.py index 90ffe9d..3a573c5 100644 --- a/backend/modules/fleet/tests/test_services.py +++ b/backend/modules/fleet/tests/test_services.py @@ -3,6 +3,7 @@ from django.test import TestCase from django.contrib.auth import get_user_model from modules.fleet.services import VehicleService from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord +from modules.task_management.models import Task class FleetServiceTests(TestCase): @@ -49,4 +50,26 @@ class FleetServiceTests(TestCase): # Nalog sa završnom kilometražom wo.end_mileage = 1100 - assert wo.distance == 100 \ No newline at end of file + assert wo.distance == 100 + + def test_closing_work_order_moves_task_to_ready_for_completion(self): + work_order = WorkOrder.objects.create( + vehicle=self.vehicle, + creator=self.user, + purpose="kontrola", + status="open", + start_mileage=5000, + end_mileage=5200, + ) + task = Task.objects.create( + title="Servis", + description="", + status="servis", + assigned_to=self.user, + vehicle=self.vehicle, + work_order=work_order, + ) + + VehicleService.update_work_order(work_order, {"status": "closed"}) + task.refresh_from_db() + self.assertEqual(task.status, "spreman_za_zavrsetak") \ No newline at end of file diff --git a/backend/modules/task_management/exceptions.py b/backend/modules/task_management/exceptions.py new file mode 100644 index 0000000..a62d8e3 --- /dev/null +++ b/backend/modules/task_management/exceptions.py @@ -0,0 +1,7 @@ +from rest_framework.exceptions import APIException + + +class OpenWorkOrderBlockingTaskClose(APIException): + status_code = 409 + default_code = 'open_work_order_blocking_task_close' + default_detail = 'Povezani putni nalog je još otvoren.' diff --git a/backend/modules/task_management/migrations/0008_task_add_ready_for_completion_status.py b/backend/modules/task_management/migrations/0008_task_add_ready_for_completion_status.py new file mode 100644 index 0000000..b3a1c3c --- /dev/null +++ b/backend/modules/task_management/migrations/0008_task_add_ready_for_completion_status.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task_management', '0007_taskworkhourstable'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='status', + field=models.CharField( + choices=[ + ('aktivan', 'Aktivan'), + ('servis', 'U tijeku'), + ('spreman_za_zavrsetak', 'Spreman za završetak'), + ('zavrsen', 'Završen'), + ('neaktivan', 'Otkazano'), + ], + default='aktivan', + max_length=20, + ), + ), + ] diff --git a/backend/modules/task_management/models.py b/backend/modules/task_management/models.py index 3129201..ca155cd 100644 --- a/backend/modules/task_management/models.py +++ b/backend/modules/task_management/models.py @@ -14,6 +14,7 @@ class Task(BaseModel): STATUS_CHOICES = [ ('aktivan', 'Aktivan'), ('servis', 'U tijeku'), + ('spreman_za_zavrsetak', 'Spreman za završetak'), ('zavrsen', 'Završen'), ('neaktivan', 'Otkazano'), ] diff --git a/backend/modules/task_management/serializers.py b/backend/modules/task_management/serializers.py index 5023f49..a874a0f 100644 --- a/backend/modules/task_management/serializers.py +++ b/backend/modules/task_management/serializers.py @@ -87,6 +87,7 @@ class TaskSerializer(serializers.ModelSerializer): work_order_label = serializers.SerializerMethodField() vehicle_registration = serializers.SerializerMethodField() template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True) + auto_close_work_order = serializers.BooleanField(write_only=True, required=False) class Meta: model = Task @@ -95,7 +96,7 @@ class TaskSerializer(serializers.ModelSerializer): 'assigned_to', 'assigned_to_name', 'vehicle', 'vehicle_registration', 'work_order', 'work_order_label', - 'template_id', + 'template_id', 'auto_close_work_order', 'created_at', ] read_only_fields = ['id', 'created_at'] diff --git a/backend/modules/task_management/services.py b/backend/modules/task_management/services.py index 5339d8f..a776731 100644 --- a/backend/modules/task_management/services.py +++ b/backend/modules/task_management/services.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError import logging from .models import Task, TaskTemplate, TaskWorkEntry +from .exceptions import OpenWorkOrderBlockingTaskClose from modules.fleet.models import VehicleServiceRecord logger = logging.getLogger(__name__) @@ -106,10 +107,34 @@ class TaskService: ) @staticmethod - def update_task_entry(instance, data, user): + def update_task_entry(instance, data, user, auto_close_work_order=False): if not user.is_staff and instance.assigned_to != user: raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.") + target_status = data.get('status', instance.status) + target_work_order = data.get('work_order', instance.work_order) + closing_statuses = {'neaktivan', 'zavrsen'} + + if target_status in closing_statuses: + if not target_work_order: + raise ValidationError({"work_order": "Putni nalog je obavezan prije zatvaranja zadatka."}) + if target_work_order.status == 'open': + if not auto_close_work_order: + work_order_label = f"WO-{str(target_work_order.id).split('-')[0].upper()}" + raise OpenWorkOrderBlockingTaskClose(detail={ + "code": "OPEN_WORK_ORDER_BLOCKING_TASK_CLOSE", + "message": f"Putni nalog {work_order_label} je još otvoren. Želite li ga automatski zatvoriti?", + "work_order_id": str(target_work_order.id), + "work_order_label": work_order_label, + }) + if target_work_order.end_mileage is None: + raise ValidationError({ + "work_order": "Automatsko zatvaranje putnog naloga nije moguće bez završne kilometraže." + }) + from modules.fleet.services import VehicleService + target_work_order = VehicleService.update_work_order(target_work_order, {"status": "closed"}) + data['work_order'] = target_work_order + for attr, value in data.items(): if attr == 'template_id': continue diff --git a/backend/modules/task_management/tests/test_services.py b/backend/modules/task_management/tests/test_services.py index 9049fdc..765d99b 100644 --- a/backend/modules/task_management/tests/test_services.py +++ b/backend/modules/task_management/tests/test_services.py @@ -2,6 +2,7 @@ from django.test import TestCase from django.contrib.auth import get_user_model from rest_framework.exceptions import ValidationError from modules.task_management.services import TaskService +from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord @@ -42,7 +43,14 @@ class TaskServiceTests(TestCase): def test_update_task_entry_allows_closing_with_work_order(self): vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01", asset_type='crane') - work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='defektaza') + work_order = WorkOrder.objects.create( + vehicle=vehicle, + creator=self.user, + purpose='defektaza', + status='closed', + start_mileage=100, + end_mileage=120, + ) task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis') updated = TaskService.update_task_entry( instance=task, @@ -59,7 +67,14 @@ class TaskServiceTests(TestCase): def test_update_task_entry_allows_finished_with_work_order(self): vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01A", asset_type='crane') - work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='kontrola') + work_order = WorkOrder.objects.create( + vehicle=vehicle, + creator=self.user, + purpose='kontrola', + status='closed', + start_mileage=100, + end_mileage=150, + ) task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis', vehicle=vehicle) updated = TaskService.update_task_entry( instance=task, @@ -69,6 +84,53 @@ class TaskServiceTests(TestCase): assert updated.status == "zavrsen" assert updated.work_order == work_order + def test_update_task_entry_blocks_closing_when_work_order_open(self): + vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01B", asset_type='crane') + work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='kontrola', status='open') + task = Task.objects.create( + title="Integritet", + description="", + assigned_to=self.user, + status='servis', + vehicle=vehicle, + work_order=work_order, + ) + + with self.assertRaises(OpenWorkOrderBlockingTaskClose): + TaskService.update_task_entry( + instance=task, + data={"status": "zavrsen"}, + user=self.user, + ) + + def test_update_task_entry_can_auto_close_open_work_order(self): + vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01C", asset_type='crane') + work_order = WorkOrder.objects.create( + vehicle=vehicle, + creator=self.user, + purpose='kontrola', + status='open', + start_mileage=100, + end_mileage=150, + ) + task = Task.objects.create( + title="Auto close", + description="", + assigned_to=self.user, + status='servis', + vehicle=vehicle, + work_order=work_order, + ) + updated = TaskService.update_task_entry( + instance=task, + data={"status": "zavrsen"}, + user=self.user, + auto_close_work_order=True, + ) + work_order.refresh_from_db() + self.assertEqual(updated.status, "zavrsen") + self.assertEqual(work_order.status, "closed") + def test_create_task_entry_from_template_creates_service_records(self): vehicle = Vehicle.objects.create(registration_number="ZG-TASK-02", asset_type='crane') template = TaskTemplate.objects.create( diff --git a/backend/modules/task_management/tests/test_views.py b/backend/modules/task_management/tests/test_views.py index 6596103..c485e97 100644 --- a/backend/modules/task_management/tests/test_views.py +++ b/backend/modules/task_management/tests/test_views.py @@ -47,7 +47,12 @@ class TaskViewSetTests(TestCase): serializer.instance = task self.view.perform_update(serializer) - mock_update.assert_called_once_with(instance=task, data=serializer.validated_data, user=self.view.request.user) + mock_update.assert_called_once_with( + instance=task, + data=serializer.validated_data, + user=self.view.request.user, + auto_close_work_order=False, + ) # confirm instance saved with new title task.refresh_from_db() assert task.title == "Updated" diff --git a/backend/modules/task_management/views.py b/backend/modules/task_management/views.py index 6dedad7..83b9a04 100644 --- a/backend/modules/task_management/views.py +++ b/backend/modules/task_management/views.py @@ -41,10 +41,12 @@ class TaskViewSet(viewsets.ModelViewSet): raise e def perform_update(self, serializer): + auto_close_work_order = serializer.validated_data.pop('auto_close_work_order', False) TaskService.update_task_entry( instance=serializer.instance, data=serializer.validated_data, user=self.request.user, + auto_close_work_order=auto_close_work_order, ) serializer.save() diff --git a/frontend/src/components/dashboard/FleetDashboardShell.jsx b/frontend/src/components/dashboard/FleetDashboardShell.jsx index 2003c61..1ce7438 100644 --- a/frontend/src/components/dashboard/FleetDashboardShell.jsx +++ b/frontend/src/components/dashboard/FleetDashboardShell.jsx @@ -33,7 +33,7 @@ import { showToast } from '../../stores/toastStore'; import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore'; import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore'; import { formatEntityCode } from '../../lib/displayIds'; -import { $tasks, fetchTasks, isTaskActive, updateTask } from '../../stores/taskStore'; +import { $tasks, fetchTasks, getStatusLabel, isTaskActive, updateTask } from '../../stores/taskStore'; import AnimatedDataTable from '../ui/AnimatedDataTable'; import AnimatedPage from '../ui/AnimatedPage'; @@ -66,6 +66,15 @@ function formatDate(value) { return parsed.toLocaleDateString('hr-HR'); } +function formatTaskCraneLabel(task, cranes) { + const crane = cranes.find((item) => String(item.id) === String(task?.vehicle || '')); + const serial = String(crane?.crane_serial_number || '').trim(); + const model = [crane?.make, crane?.model].filter(Boolean).join(' ').trim(); + const registration = String(task?.vehicle_registration || crane?.registration_number || '').trim(); + const primary = serial ? `SN ${serial}` : (registration || '-'); + return model ? `${primary} / ${model}` : primary; +} + function formatCost(value) { const numeric = Number(value); if (Number.isNaN(numeric)) return '-'; @@ -663,14 +672,17 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
- Radni zadatak: {task.title || '-'} • Status: {task.status || '-'} • Zapisa: {records.length} + Radni zadatak: {task.title || '-'} • Status: {getStatusLabel(task.status || '-')} • Zapisa: {records.length}