feat: integritet zatvaranja taska i putnog naloga

Dodaje status 'spreman_za_zavrsetak' i backend provjeru koja blokira zatvaranje taska kada je povezani putni nalog otvoren, uz 409 odgovor i opcionalni auto-close flow.\n\nPri zatvaranju putnog naloga taskovi u statusu 'servis' automatski prelaze u 'spreman_za_zavrsetak'. Frontend modal sada podržava potvrdu i retry s auto_close_work_order=true.\n\nUključeni su i ciljani testovi za novi behavior, te ažuriran prikaz statusa u dashboardu.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-17 19:20:33 +02:00
parent e14665d8ec
commit 7acaef941f
14 changed files with 408 additions and 18 deletions

View File

@@ -126,6 +126,7 @@ class VehicleService:
@staticmethod @staticmethod
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder: def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
previous_status = instance.status
for k, v in data.items(): for k, v in data.items():
setattr(instance, k, v) setattr(instance, k, v)
try: try:
@@ -134,8 +135,17 @@ class VehicleService:
raise ValidationError({"detail": str(e)}) raise ValidationError({"detail": str(e)})
with transaction.atomic(): with transaction.atomic():
instance.save() instance.save()
# Ako je nalog zatvoren, obavijestimo kreatora/servisera transitioned_to_closed = previous_status != 'closed' and instance.status == 'closed'
if 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: try:
NotificationService.create_notification( NotificationService.create_notification(
recipient=instance.creator, recipient=instance.creator,

View File

@@ -3,6 +3,7 @@ from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from modules.fleet.services import VehicleService from modules.fleet.services import VehicleService
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
from modules.task_management.models import Task
class FleetServiceTests(TestCase): class FleetServiceTests(TestCase):
@@ -49,4 +50,26 @@ class FleetServiceTests(TestCase):
# Nalog sa završnom kilometražom # Nalog sa završnom kilometražom
wo.end_mileage = 1100 wo.end_mileage = 1100
assert wo.distance == 100 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")

View File

@@ -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.'

View File

@@ -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,
),
),
]

View File

@@ -14,6 +14,7 @@ class Task(BaseModel):
STATUS_CHOICES = [ STATUS_CHOICES = [
('aktivan', 'Aktivan'), ('aktivan', 'Aktivan'),
('servis', 'U tijeku'), ('servis', 'U tijeku'),
('spreman_za_zavrsetak', 'Spreman za završetak'),
('zavrsen', 'Završen'), ('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'), ('neaktivan', 'Otkazano'),
] ]

View File

@@ -87,6 +87,7 @@ class TaskSerializer(serializers.ModelSerializer):
work_order_label = serializers.SerializerMethodField() work_order_label = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField() vehicle_registration = serializers.SerializerMethodField()
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True) 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: class Meta:
model = Task model = Task
@@ -95,7 +96,7 @@ class TaskSerializer(serializers.ModelSerializer):
'assigned_to', 'assigned_to_name', 'assigned_to', 'assigned_to_name',
'vehicle', 'vehicle_registration', 'vehicle', 'vehicle_registration',
'work_order', 'work_order_label', 'work_order', 'work_order_label',
'template_id', 'template_id', 'auto_close_work_order',
'created_at', 'created_at',
] ]
read_only_fields = ['id', 'created_at'] read_only_fields = ['id', 'created_at']

View File

@@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError
import logging import logging
from .models import Task, TaskTemplate, TaskWorkEntry from .models import Task, TaskTemplate, TaskWorkEntry
from .exceptions import OpenWorkOrderBlockingTaskClose
from modules.fleet.models import VehicleServiceRecord from modules.fleet.models import VehicleServiceRecord
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -106,10 +107,34 @@ class TaskService:
) )
@staticmethod @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: if not user.is_staff and instance.assigned_to != user:
raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.") 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(): for attr, value in data.items():
if attr == 'template_id': if attr == 'template_id':
continue continue

View File

@@ -2,6 +2,7 @@ from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
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.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord 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): def test_update_task_entry_allows_closing_with_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01", asset_type='crane') 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') task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis')
updated = TaskService.update_task_entry( updated = TaskService.update_task_entry(
instance=task, instance=task,
@@ -59,7 +67,14 @@ class TaskServiceTests(TestCase):
def test_update_task_entry_allows_finished_with_work_order(self): def test_update_task_entry_allows_finished_with_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01A", asset_type='crane') 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) task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis', vehicle=vehicle)
updated = TaskService.update_task_entry( updated = TaskService.update_task_entry(
instance=task, instance=task,
@@ -69,6 +84,53 @@ class TaskServiceTests(TestCase):
assert updated.status == "zavrsen" assert updated.status == "zavrsen"
assert updated.work_order == work_order 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): def test_create_task_entry_from_template_creates_service_records(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-02", asset_type='crane') vehicle = Vehicle.objects.create(registration_number="ZG-TASK-02", asset_type='crane')
template = TaskTemplate.objects.create( template = TaskTemplate.objects.create(

View File

@@ -47,7 +47,12 @@ class TaskViewSetTests(TestCase):
serializer.instance = task serializer.instance = task
self.view.perform_update(serializer) 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 # confirm instance saved with new title
task.refresh_from_db() task.refresh_from_db()
assert task.title == "Updated" assert task.title == "Updated"

View File

@@ -41,10 +41,12 @@ class TaskViewSet(viewsets.ModelViewSet):
raise e raise e
def perform_update(self, serializer): def perform_update(self, serializer):
auto_close_work_order = serializer.validated_data.pop('auto_close_work_order', False)
TaskService.update_task_entry( TaskService.update_task_entry(
instance=serializer.instance, instance=serializer.instance,
data=serializer.validated_data, data=serializer.validated_data,
user=self.request.user, user=self.request.user,
auto_close_work_order=auto_close_work_order,
) )
serializer.save() serializer.save()

View File

@@ -33,7 +33,7 @@ import { showToast } from '../../stores/toastStore';
import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore'; import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore';
import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore'; import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore';
import { formatEntityCode } from '../../lib/displayIds'; 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 AnimatedDataTable from '../ui/AnimatedDataTable';
import AnimatedPage from '../ui/AnimatedPage'; import AnimatedPage from '../ui/AnimatedPage';
@@ -66,6 +66,15 @@ function formatDate(value) {
return parsed.toLocaleDateString('hr-HR'); 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) { function formatCost(value) {
const numeric = Number(value); const numeric = Number(value);
if (Number.isNaN(numeric)) return '-'; if (Number.isNaN(numeric)) return '-';
@@ -663,14 +672,17 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={isTaskActive(task) ? 'text-emerald-700' : 'text-red-700'}> <span className={isTaskActive(task) ? 'text-emerald-700' : 'text-red-700'}>
{task.status || '-'} {getStatusLabel(task.status || '-')}
</span> </span>
</td> </td>
<td className="px-4 py-3">{formatDateTime(task.created_at)}</td> <td
className="px-4 py-3"
title={formatDateTime(task.created_at) !== '-' ? formatDateTime(task.created_at) : undefined}
>
<span className="cursor-help">{formatDate(task.created_at)}</span>
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{task.vehicle_registration {formatTaskCraneLabel(task, cranes)}
|| cranes.find((item) => String(item.id) === String(task.vehicle))?.registration_number
|| '-'}
</td> </td>
<td className="px-4 py-3">{task.work_order_label || '-'}</td> <td className="px-4 py-3">{task.work_order_label || '-'}</td>
<td className="px-4 py-3">{task.assigned_to_name || '-'}</td> <td className="px-4 py-3">{task.assigned_to_name || '-'}</td>
@@ -1138,6 +1150,12 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setSelectedTask(updatedTask); setSelectedTask(updatedTask);
return updatedTask; return updatedTask;
}} }}
onUpdateTask={async (task, changes) => {
const updatedTask = await updateTask(task.id, changes);
setSelectedTask(updatedTask);
await fetchFleetDashboardData({ silent: true });
return updatedTask;
}}
onCreateServiceRecord={(task) => { onCreateServiceRecord={(task) => {
const taskCrane = cranes.find((item) => String(item.id) === String(task?.vehicle)); const taskCrane = cranes.find((item) => String(item.id) === String(task?.vehicle));
setSelectedTask(null); setSelectedTask(null);

View File

@@ -1,6 +1,9 @@
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell'; import ModalShell from '../ui/ModalShell';
import { formatEntityCode } from '../../lib/displayIds'; import { formatEntityCode } from '../../lib/displayIds';
import { getStatusLabel } from '../../stores/taskStore';
const STATUS_OPTIONS = ['aktivan', 'servis', 'spreman_za_zavrsetak', 'zavrsen', 'neaktivan'];
function formatDate(value) { function formatDate(value) {
if (!value) return '-'; if (!value) return '-';
@@ -9,6 +12,13 @@ function formatDate(value) {
return parsed.toLocaleDateString('hr-HR'); return parsed.toLocaleDateString('hr-HR');
} }
function formatDateTime(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleString('hr-HR');
}
function formatCost(value) { function formatCost(value) {
const numeric = Number(value); const numeric = Number(value);
if (Number.isNaN(numeric)) return '-'; if (Number.isNaN(numeric)) return '-';
@@ -23,12 +33,21 @@ export default function TaskServiceRecordsModal({
onClose, onClose,
onOpenServiceRecord, onOpenServiceRecord,
onAssignWorkOrder, onAssignWorkOrder,
onUpdateTask,
onCreateServiceRecord, onCreateServiceRecord,
}) { }) {
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false); const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState(''); const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
const [assigningWorkOrder, setAssigningWorkOrder] = useState(false); const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
const [assignError, setAssignError] = useState(''); const [assignError, setAssignError] = useState('');
const [showTaskEditor, setShowTaskEditor] = useState(false);
const [updatingTask, setUpdatingTask] = useState(false);
const [taskError, setTaskError] = useState('');
const [taskForm, setTaskForm] = useState({
title: '',
description: '',
status: 'aktivan',
});
useEffect(() => { useEffect(() => {
if (!open || typeof window === 'undefined') return; if (!open || typeof window === 'undefined') return;
@@ -41,6 +60,14 @@ export default function TaskServiceRecordsModal({
setShowWorkOrderEditor(!task.work_order); setShowWorkOrderEditor(!task.work_order);
setAssignError(''); setAssignError('');
setAssigningWorkOrder(false); setAssigningWorkOrder(false);
setShowTaskEditor(false);
setTaskError('');
setUpdatingTask(false);
setTaskForm({
title: String(task.title || ''),
description: String(task.description || ''),
status: String(task.status || 'aktivan'),
});
}, [task?.id, task?.work_order]); }, [task?.id, task?.work_order]);
const currentWorkOrder = useMemo( const currentWorkOrder = useMemo(
@@ -53,6 +80,8 @@ export default function TaskServiceRecordsModal({
[workOrders] [workOrders]
); );
const effectiveWorkOrderId = task?.work_order ? String(task.work_order) : String(selectedWorkOrderId || '');
const handleWorkOrderChange = async (event) => { const handleWorkOrderChange = async (event) => {
const nextValue = String(event.currentTarget.value || ''); const nextValue = String(event.currentTarget.value || '');
setSelectedWorkOrderId(nextValue); setSelectedWorkOrderId(nextValue);
@@ -68,6 +97,57 @@ export default function TaskServiceRecordsModal({
} }
}; };
const handleTaskUpdate = async (event) => {
event.preventDefault();
const title = String(taskForm.title || '').trim();
const status = String(taskForm.status || 'aktivan');
const description = String(taskForm.description || '').trim();
if (!title) {
setTaskError('Naslov zadatka je obavezan.');
return;
}
if ((status === 'zavrsen' || status === 'neaktivan') && !effectiveWorkOrderId) {
setTaskError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
setTaskError('');
setUpdatingTask(true);
try {
await onUpdateTask?.(task, {
title,
description,
status,
});
setShowTaskEditor(false);
} catch (error) {
const conflictCode = error?.details?.code;
if (error?.status === 409 && conflictCode === 'OPEN_WORK_ORDER_BLOCKING_TASK_CLOSE') {
const confirmText = error?.details?.message || error?.message || 'Povezani putni nalog je još otvoren. Želite li ga automatski zatvoriti?';
const approved = typeof window !== 'undefined' ? window.confirm(confirmText) : false;
if (approved) {
try {
await onUpdateTask?.(task, {
title,
description,
status,
auto_close_work_order: true,
});
setShowTaskEditor(false);
return;
} catch (retryError) {
setTaskError(retryError?.message || 'Neuspješno automatsko zatvaranje putnog naloga i završetak zadatka.');
return;
}
}
setTaskError(confirmText);
return;
}
setTaskError(error?.message || 'Neuspješno spremanje izmjena radnog zadatka.');
} finally {
setUpdatingTask(false);
}
};
if (!open || !task) return null; if (!open || !task) return null;
return ( return (
@@ -82,7 +162,7 @@ export default function TaskServiceRecordsModal({
<div> <div>
<h3 className="text-lg font-semibold text-text-main">Radni zadatak detalji</h3> <h3 className="text-lg font-semibold text-text-main">Radni zadatak detalji</h3>
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
Radni zadatak: {task.title || '-'} Status: {task.status || '-'} Zapisa: {records.length} Radni zadatak: {task.title || '-'} Status: {getStatusLabel(task.status || '-')} Zapisa: {records.length}
</p> </p>
</div> </div>
<button <button
@@ -97,6 +177,125 @@ export default function TaskServiceRecordsModal({
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4"> <div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-text-main">Podaci radnog zadatka</h4>
{!showTaskEditor ? (
<button
type="button"
onClick={() => {
setTaskError('');
setShowTaskEditor(true);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Izmijeni radni zadatak
</button>
) : null}
</div>
{!showTaskEditor ? (
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-text-muted">Naslov</dt>
<dd className="text-text-main">{task.title || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Status</dt>
<dd className="text-text-main">{getStatusLabel(task.status || '-')}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Dodijeljeno</dt>
<dd className="text-text-main">{task.assigned_to_name || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Dizalica</dt>
<dd className="text-text-main">{task.vehicle_registration || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Putni nalog</dt>
<dd className="text-text-main">{task.work_order_label || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Vrijeme izrade</dt>
<dd className="text-text-main">{formatDateTime(task.created_at)}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-text-muted">Opis</dt>
<dd className="whitespace-pre-wrap text-text-main">{task.description || '-'}</dd>
</div>
</dl>
) : (
<form onSubmit={handleTaskUpdate} className="space-y-3">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Naslov *</span>
<input
type="text"
value={taskForm.title}
onInput={(event) => setTaskForm((prev) => ({ ...prev, title: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Opis</span>
<textarea
rows={3}
value={taskForm.description}
onInput={(event) => setTaskForm((prev) => ({ ...prev, description: event.currentTarget.value }))}
disabled={updatingTask}
className="resize-none rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Status</span>
<select
value={taskForm.status}
onChange={(event) => setTaskForm((prev) => ({ ...prev, status: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{getStatusLabel(status)}
</option>
))}
</select>
</label>
{taskError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{taskError}
</div>
) : null}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setTaskError('');
setShowTaskEditor(false);
setTaskForm({
title: String(task.title || ''),
description: String(task.description || ''),
status: String(task.status || 'aktivan'),
});
}}
disabled={updatingTask}
className="rounded border border-border-hairline px-3 py-1.5 text-xs text-text-main hover:bg-canvas-elevated disabled:opacity-60"
>
Odustani
</button>
<button
type="submit"
disabled={updatingTask}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{updatingTask ? 'Spremanje...' : 'Spremi izmjene'}
</button>
</div>
</form>
)}
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3"> <div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni nalozi</h4> <h4 className="mb-2 text-sm font-semibold text-text-main">Putni nalozi</h4>
{currentWorkOrder && !showWorkOrderEditor ? ( {currentWorkOrder && !showWorkOrderEditor ? (

View File

@@ -105,8 +105,18 @@ async function request(endpoint, options = {}) {
// Ako je greška DRF validacijska, flatten-iraj je u string ili objekt // Ako je greška DRF validacijska, flatten-iraj je u string ili objekt
throw { message: Object.values(errData).flat().join(', '), details: errData, status: 400 }; throw { message: Object.values(errData).flat().join(', '), details: errData, status: 400 };
} }
if (errData.detail) errorMessage = errData.detail; const normalizedDetails = (
throw { message: String(errorMessage), status: response.status }; errData
&& typeof errData === 'object'
&& typeof errData.detail === 'object'
&& errData.detail !== null
) ? errData.detail : errData;
if (typeof errData.detail === 'string' && errData.detail.trim()) {
errorMessage = errData.detail;
} else if (typeof normalizedDetails?.message === 'string' && normalizedDetails.message.trim()) {
errorMessage = normalizedDetails.message;
}
throw { message: String(errorMessage), status: response.status, details: normalizedDetails };
} }
if (options.responseType === 'blob') return await response.blob(); if (options.responseType === 'blob') return await response.blob();

View File

@@ -11,6 +11,7 @@ export const $taskTemplatesLoading = atom(false);
const STATUS_LABELS = { const STATUS_LABELS = {
aktivan: 'Aktivan', aktivan: 'Aktivan',
servis: 'U tijeku', servis: 'U tijeku',
spreman_za_zavrsetak: 'Spreman za završetak',
zavrsen: 'Završen', zavrsen: 'Završen',
neaktivan: 'Otkazano', neaktivan: 'Otkazano',
}; };
@@ -21,7 +22,7 @@ export function getStatusLabel(status) {
export function isTaskActive(task) { export function isTaskActive(task) {
const status = String(task?.status || '').toLowerCase(); const status = String(task?.status || '').toLowerCase();
return status === 'aktivan' || status === 'servis'; return status === 'aktivan' || status === 'servis' || status === 'spreman_za_zavrsetak';
} }
export async function fetchTasks() { export async function fetchTasks() {