feat: WorkOrderPhoto izolacija, upload modal i SN stupac u tablici putnih naloga
- Dodan WorkOrderPhoto model (FK na WorkOrder, odvojen od VehicleServicePhoto) - Migracija 0022_workorderphoto - WorkOrderViewSet.images action refaktoriran: GET/POST koristi WorkOrderPhoto - WorkOrderPhotoSerializer dodan u serializers.py - WorkOrderPhotoInline dodan u admin.py - Novi WorkOrderPhotoUpload.jsx modal s podrskom za onBack prop i z-[60] - WorkOrderDetailModal: gumb Dodaj fotografije u editMode, photoUploadOpen state, ispravljen onSuccess callback (map na URL stringove), reset pri zatvaranju - FleetDashboardShell: stupac SN u tablici putnih naloga (craneInfo.crane_serial_number) - fleetDashboardStore: uklonjen dupli toast iz uploadWorkOrderPhoto - Dodan test test_work_order_images_endpoint.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -4,3 +4,5 @@ serviser2@example.com,Ime2,Prezime2,password123,true,false,true,false,false,true
|
|||||||
serviser3@example.com,Ime3,Prezime3,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
|
serviser3@example.com,Ime3,Prezime3,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
|
||||||
admin1@example.com,Admin1,Prezime1,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
|
admin1@example.com,Admin1,Prezime1,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
|
||||||
admin2@example.com,Admin2,Prezime2,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
|
admin2@example.com,Admin2,Prezime2,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
|
||||||
|
ramo@knezljubo.hr,Ramo,Husanović,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
|
||||||
|
domagoj@knezljubo.hr,Domagoj,Knez,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
|
||||||
|
@@ -5,6 +5,7 @@ from .models import (
|
|||||||
Crane,
|
Crane,
|
||||||
Vehicle,
|
Vehicle,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
@@ -60,6 +61,26 @@ class WorkOrderInvoiceInline(admin.TabularInline):
|
|||||||
readonly_fields = ('created_at',)
|
readonly_fields = ('created_at',)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderPhotoInline(admin.TabularInline):
|
||||||
|
model = WorkOrderPhoto
|
||||||
|
extra = 0
|
||||||
|
fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at', 'is_active')
|
||||||
|
readonly_fields = ('thumbnail_preview', 'created_at')
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
@admin.display(description="Pregled")
|
||||||
|
def thumbnail_preview(self, obj):
|
||||||
|
if obj.image:
|
||||||
|
return format_html(
|
||||||
|
'<a href="{}" target="_blank">'
|
||||||
|
'<img src="{}" style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
|
||||||
|
'</a>',
|
||||||
|
obj.image.url,
|
||||||
|
obj.image.url,
|
||||||
|
)
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Vehicle)
|
@admin.register(Vehicle)
|
||||||
class VehicleAdmin(admin.ModelAdmin):
|
class VehicleAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
@@ -159,7 +180,7 @@ class WorkOrderAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('vehicle__registration_number', 'creator__username', 'purpose')
|
search_fields = ('vehicle__registration_number', 'creator__username', 'purpose')
|
||||||
readonly_fields = ('date',)
|
readonly_fields = ('date',)
|
||||||
ordering = ('-date',)
|
ordering = ('-date',)
|
||||||
inlines = (WorkOrderInvoiceInline,)
|
inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline)
|
||||||
|
|
||||||
def distance_display(self, obj):
|
def distance_display(self, obj):
|
||||||
return obj.distance
|
return obj.distance
|
||||||
|
|||||||
43
backend/modules/fleet/migrations/0022_workorderphoto.py
Normal file
43
backend/modules/fleet/migrations/0022_workorderphoto.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def work_order_photo_upload_to(instance, filename):
|
||||||
|
vehicle_reg = 'unknown'
|
||||||
|
try:
|
||||||
|
vehicle_reg = instance.work_order.vehicle.registration_number
|
||||||
|
except Exception:
|
||||||
|
vehicle_reg = 'unknown'
|
||||||
|
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||||
|
return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0021_alter_vehicle_assigned_servicer_many_to_one'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WorkOrderPhoto',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('image', models.ImageField(upload_to=work_order_photo_upload_to, verbose_name='Fotografija putnog naloga')),
|
||||||
|
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
|
||||||
|
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_work_order_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
|
||||||
|
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Fotografija putnog naloga',
|
||||||
|
'verbose_name_plural': 'Fotografije putnog naloga',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -45,6 +45,15 @@ def _work_order_invoice_upload_to(instance, filename):
|
|||||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||||
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
|
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
|
||||||
|
|
||||||
|
def _work_order_photo_upload_to(instance, filename):
|
||||||
|
vehicle_reg = 'unknown'
|
||||||
|
try:
|
||||||
|
vehicle_reg = instance.work_order.vehicle.registration_number
|
||||||
|
except Exception:
|
||||||
|
vehicle_reg = 'unknown'
|
||||||
|
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||||
|
return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}"
|
||||||
|
|
||||||
|
|
||||||
class VehicleServicePhoto(BaseModel):
|
class VehicleServicePhoto(BaseModel):
|
||||||
"""
|
"""
|
||||||
@@ -344,6 +353,37 @@ class WorkOrderInvoice(BaseModel):
|
|||||||
return f"{self.naziv_racuna} ({self.work_order_id})"
|
return f"{self.naziv_racuna} ({self.work_order_id})"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderPhoto(BaseModel):
|
||||||
|
work_order = models.ForeignKey(
|
||||||
|
WorkOrder,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='photos',
|
||||||
|
verbose_name=_("Putni nalog"),
|
||||||
|
)
|
||||||
|
image = models.ImageField(
|
||||||
|
upload_to=_work_order_photo_upload_to,
|
||||||
|
verbose_name=_("Fotografija putnog naloga"),
|
||||||
|
)
|
||||||
|
description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
|
||||||
|
uploaded_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='uploaded_work_order_photos',
|
||||||
|
verbose_name=_("Postavio"),
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Fotografija putnog naloga")
|
||||||
|
verbose_name_plural = _("Fotografije putnog naloga")
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"WorkOrderPhoto {self.pk} ({self.work_order_id})"
|
||||||
|
|
||||||
|
|
||||||
class VehicleServiceRecord(BaseModel):
|
class VehicleServiceRecord(BaseModel):
|
||||||
"""
|
"""
|
||||||
Zapisi o servisima/popravcima na vozilu.
|
Zapisi o servisima/popravcima na vozilu.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from .models import (
|
|||||||
Vehicle,
|
Vehicle,
|
||||||
VehicleNotification,
|
VehicleNotification,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
@@ -198,6 +199,16 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ['id', 'created_by', 'created_at']
|
read_only_fields = ['id', 'created_by', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = WorkOrderPhoto
|
||||||
|
fields = ['id', 'work_order', 'image', 'description', 'uploaded_by', 'created_at']
|
||||||
|
read_only_fields = ['id', 'uploaded_by', 'created_at']
|
||||||
|
extra_kwargs = {
|
||||||
|
'work_order': {'required': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
|
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
|
||||||
crane = serializers.SerializerMethodField()
|
crane = serializers.SerializerMethodField()
|
||||||
crane_registration = serializers.SerializerMethodField()
|
crane_registration = serializers.SerializerMethodField()
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
import uuid
|
||||||
|
from io import BytesIO
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleServicePhoto
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
|
||||||
|
file = BytesIO()
|
||||||
|
image = Image.new('RGB', size=size, color=color)
|
||||||
|
image.save(file, 'JPEG')
|
||||||
|
file.seek(0)
|
||||||
|
return SimpleUploadedFile(
|
||||||
|
name=filename,
|
||||||
|
content=file.getvalue(),
|
||||||
|
content_type='image/jpeg',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderImagesEndpointTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user_model = get_user_model()
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
self.user = user_model.objects.create_user(
|
||||||
|
username=f'wo-img-user-{suffix}',
|
||||||
|
email=f'wo-img-user-{suffix}@example.test',
|
||||||
|
password='test1234',
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
self.vehicle = Vehicle.objects.create(
|
||||||
|
asset_type='crane',
|
||||||
|
registration_number='WO-IMG-001',
|
||||||
|
crane_serial_number='123456',
|
||||||
|
)
|
||||||
|
self.work_order = WorkOrder.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
creator=self.user,
|
||||||
|
purpose='kontrola',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.service_record = VehicleServiceRecord.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
performed_by=self.user,
|
||||||
|
description='Servisni zapis za test izolacije fotografija.',
|
||||||
|
service_title='Test servis',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_work_order_images_endpoint_isolated_from_service_record_photos(self):
|
||||||
|
# Existing service photo must NOT appear in work-order images response
|
||||||
|
VehicleServicePhoto.objects.create(
|
||||||
|
service_record=self.service_record,
|
||||||
|
image=create_test_image('service.jpg'),
|
||||||
|
description='Service photo',
|
||||||
|
uploaded_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
|
||||||
|
response = self.client.post(
|
||||||
|
upload_url,
|
||||||
|
data={
|
||||||
|
'image': create_test_image('workorder.jpg'),
|
||||||
|
'description': 'Work order photo',
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.data)
|
||||||
|
self.assertEqual(str(response.data.get('work_order')), str(self.work_order.pk))
|
||||||
|
|
||||||
|
list_response = self.client.get(upload_url)
|
||||||
|
self.assertEqual(list_response.status_code, 200)
|
||||||
|
images = list_response.data.get('images', [])
|
||||||
|
self.assertEqual(len(images), 1)
|
||||||
|
self.assertEqual(str(images[0].get('work_order_id')), str(self.work_order.pk))
|
||||||
|
|
||||||
|
def test_service_record_files_endpoint_does_not_return_work_order_images(self):
|
||||||
|
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
|
||||||
|
self.client.post(
|
||||||
|
upload_url,
|
||||||
|
data={
|
||||||
|
'image': create_test_image('workorder-2.jpg'),
|
||||||
|
'description': 'WO isolated',
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
|
||||||
|
service_photos_url = f"{reverse('service-photo-list')}?service_record_id={self.service_record.pk}"
|
||||||
|
response = self.client.get(service_photos_url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.data), 0)
|
||||||
@@ -30,6 +30,7 @@ from .models import (
|
|||||||
Vehicle,
|
Vehicle,
|
||||||
VehicleNotification,
|
VehicleNotification,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
@@ -40,6 +41,7 @@ from .serializers import (
|
|||||||
VehicleSerializer,
|
VehicleSerializer,
|
||||||
WorkOrderSerializer,
|
WorkOrderSerializer,
|
||||||
WorkOrderInvoiceSerializer,
|
WorkOrderInvoiceSerializer,
|
||||||
|
WorkOrderPhotoSerializer,
|
||||||
VehicleServiceRecordSerializer,
|
VehicleServiceRecordSerializer,
|
||||||
VehicleNotificationSerializer,
|
VehicleNotificationSerializer,
|
||||||
VehicleServicePhotoSerializer,
|
VehicleServicePhotoSerializer,
|
||||||
@@ -1240,10 +1242,15 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return _work_orders_queryset_for_user(self.request.user)
|
return _work_orders_queryset_for_user(self.request.user)
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='images')
|
@action(detail=True, methods=['get', 'post'], url_path='images', parser_classes=[MultiPartParser, FormParser])
|
||||||
def images(self, request, pk=None):
|
def images(self, request, pk=None):
|
||||||
work_order = self.get_object()
|
work_order = self.get_object()
|
||||||
service_record_id = request.query_params.get('service_record_id')
|
if request.method.lower() == 'post':
|
||||||
|
serializer = WorkOrderPhotoSerializer(data=request.data, context={'request': request})
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save(work_order=work_order, uploaded_by=request.user)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
quality = _parse_positive_int(
|
quality = _parse_positive_int(
|
||||||
request.query_params.get('q'),
|
request.query_params.get('q'),
|
||||||
field_name='q',
|
field_name='q',
|
||||||
@@ -1259,60 +1266,29 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
max_value=3840,
|
max_value=3840,
|
||||||
)
|
)
|
||||||
image_format, _ = _parse_format(request.query_params.get('fmt'))
|
image_format, _ = _parse_format(request.query_params.get('fmt'))
|
||||||
work_order_task_ids = list(
|
photos_qs = WorkOrderPhoto.objects.filter(
|
||||||
work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not work_order_task_ids:
|
|
||||||
return Response({
|
|
||||||
"work_order_id": work_order.pk,
|
|
||||||
"vehicle_id": work_order.vehicle_id,
|
|
||||||
"service_record_id": service_record_id or None,
|
|
||||||
"images": [],
|
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
photos_qs = VehicleServicePhoto.objects.filter(
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
service_record__is_active=True,
|
work_order_id=work_order.pk,
|
||||||
service_record__vehicle_id=work_order.vehicle_id,
|
).select_related('uploaded_by').order_by('-created_at')
|
||||||
service_record__task_id__in=work_order_task_ids,
|
|
||||||
).select_related('service_record', 'uploaded_by').order_by('-created_at')
|
|
||||||
|
|
||||||
if not request.user.is_staff:
|
|
||||||
photos_qs = photos_qs.filter(service_record__performed_by=request.user)
|
|
||||||
|
|
||||||
if service_record_id:
|
|
||||||
record = _service_records_queryset_for_user(request.user).filter(
|
|
||||||
pk=service_record_id,
|
|
||||||
vehicle_id=work_order.vehicle_id,
|
|
||||||
task_id__in=work_order_task_ids,
|
|
||||||
).first()
|
|
||||||
if record is None:
|
|
||||||
raise DRFValidationError({
|
|
||||||
"service_record_id": "Servisni zapis nije pronađen ili ne pripada ovom nalogu."
|
|
||||||
})
|
|
||||||
photos_qs = photos_qs.filter(service_record_id=record.pk)
|
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
for photo in photos_qs:
|
for photo in photos_qs:
|
||||||
optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk})
|
|
||||||
optimized_url = request.build_absolute_uri(
|
|
||||||
f"{optimized_path}?w={width}&q={quality}&fmt={image_format.lower()}"
|
|
||||||
)
|
|
||||||
original_url = request.build_absolute_uri(photo.image.url) if photo.image else None
|
original_url = request.build_absolute_uri(photo.image.url) if photo.image else None
|
||||||
images.append({
|
images.append({
|
||||||
"id": photo.pk,
|
"id": photo.pk,
|
||||||
"service_record_id": photo.service_record_id,
|
"work_order_id": photo.work_order_id,
|
||||||
"description": photo.description,
|
"description": photo.description,
|
||||||
"original_url": original_url,
|
"original_url": original_url,
|
||||||
"optimized_url": optimized_url,
|
"optimized_url": original_url,
|
||||||
"created_at": photo.created_at,
|
"created_at": photo.created_at,
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
"work_order_id": work_order.pk,
|
"work_order_id": work_order.pk,
|
||||||
"vehicle_id": work_order.vehicle_id,
|
"vehicle_id": work_order.vehicle_id,
|
||||||
"service_record_id": service_record_id or None,
|
"image_quality": quality,
|
||||||
|
"image_width": width,
|
||||||
|
"image_format": image_format.lower(),
|
||||||
"images": images,
|
"images": images,
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import WorkOrderDetailModal from './WorkOrderDetailModal';
|
|||||||
import SyncLogPanel from './SyncLogPanel';
|
import SyncLogPanel from './SyncLogPanel';
|
||||||
import ServiceRecordForm from '../fleet/ServiceRecordForm';
|
import ServiceRecordForm from '../fleet/ServiceRecordForm';
|
||||||
import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
|
import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
|
||||||
|
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||||
import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
|
import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
|
||||||
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
||||||
import ClientsSection from '../crm/ClientsSection';
|
import ClientsSection from '../crm/ClientsSection';
|
||||||
@@ -93,6 +94,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
|
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
|
||||||
// photoUpload: { open: bool, serviceRecordId: string|null }
|
// photoUpload: { open: bool, serviceRecordId: string|null }
|
||||||
const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null });
|
const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null });
|
||||||
|
const [workOrderPhotoUpload, setWorkOrderPhotoUpload] = useState({ open: false, workOrderId: null });
|
||||||
const loading = useStore($dashboardLoading);
|
const loading = useStore($dashboardLoading);
|
||||||
const error = useStore($dashboardError);
|
const error = useStore($dashboardError);
|
||||||
const stats = useStore($dashboardStats);
|
const stats = useStore($dashboardStats);
|
||||||
@@ -492,6 +494,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
const workOrderColumns = [
|
const workOrderColumns = [
|
||||||
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
|
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
|
||||||
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
|
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
|
||||||
|
{ key: 'serialNumber', label: 'SN', className: 'px-4 py-3' },
|
||||||
{ key: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' },
|
{ key: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' },
|
||||||
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
|
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
|
||||||
{ key: 'creator', label: 'Creator', className: 'px-4 py-3' },
|
{ key: 'creator', label: 'Creator', className: 'px-4 py-3' },
|
||||||
@@ -756,6 +759,9 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
>
|
>
|
||||||
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
|
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">
|
||||||
|
{workOrder.craneInfo?.crane_serial_number || '-'}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{formatDate(workOrder.date)}
|
{formatDate(workOrder.date)}
|
||||||
</td>
|
</td>
|
||||||
@@ -773,15 +779,10 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const related = serviceRecords.find((record) => String(record.vehicle) === String(workOrder.vehicle));
|
setWorkOrderPhotoUpload({ open: true, workOrderId: workOrder.id });
|
||||||
if (!related) {
|
|
||||||
showToast('Nema servisnog zapisa za ovaj putni nalog. Kreirajte servisni zapis prvo.', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPhotoUpload({ open: true, serviceRecordId: related.id });
|
|
||||||
}}
|
}}
|
||||||
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
|
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
|
||||||
title="Upload fotografija"
|
title="Upload fotografija putnog naloga"
|
||||||
>
|
>
|
||||||
📷
|
📷
|
||||||
</button>
|
</button>
|
||||||
@@ -913,7 +914,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
{JSON.stringify(group, null, 2)}
|
{JSON.stringify(group, null, 2)}
|
||||||
</pre> */}
|
</pre> */}
|
||||||
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
|
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
|
||||||
<td className="px-4 py-3">{record.crane_serial_number || record.vehicle_registration || '-'}</td>
|
<td className="px-4 py-3">{record.crane_serial_number || '-'}</td>
|
||||||
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
|
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
|
||||||
{record.description || '-'}
|
{record.description || '-'}
|
||||||
</td>
|
</td>
|
||||||
@@ -1094,6 +1095,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WorkOrderPhotoUpload
|
||||||
|
open={workOrderPhotoUpload.open}
|
||||||
|
workOrderId={workOrderPhotoUpload.workOrderId}
|
||||||
|
onClose={() => setWorkOrderPhotoUpload({ open: false, workOrderId: null })}
|
||||||
|
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
||||||
|
/>
|
||||||
|
|
||||||
<ServiceRecordDetailModal
|
<ServiceRecordDetailModal
|
||||||
open={!!selectedServiceRecord}
|
open={!!selectedServiceRecord}
|
||||||
serviceRecord={selectedServiceRecord}
|
serviceRecord={selectedServiceRecord}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
||||||
|
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||||
import ModalShell from '../ui/ModalShell';
|
import ModalShell from '../ui/ModalShell';
|
||||||
import {
|
import {
|
||||||
createWorkOrderInvoice,
|
createWorkOrderInvoice,
|
||||||
@@ -55,6 +56,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
});
|
});
|
||||||
const [invoices, setInvoices] = useState([]);
|
const [invoices, setInvoices] = useState([]);
|
||||||
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||||
|
const [photoUploadOpen, setPhotoUploadOpen] = useState(false);
|
||||||
const [invoiceSaving, setInvoiceSaving] = useState(false);
|
const [invoiceSaving, setInvoiceSaving] = useState(false);
|
||||||
const [invoiceForm, setInvoiceForm] = useState({
|
const [invoiceForm, setInvoiceForm] = useState({
|
||||||
naziv_racuna: '',
|
naziv_racuna: '',
|
||||||
@@ -133,6 +135,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
});
|
});
|
||||||
setInvoices([]);
|
setInvoices([]);
|
||||||
setInvoiceModalOpen(false);
|
setInvoiceModalOpen(false);
|
||||||
|
setPhotoUploadOpen(false);
|
||||||
setInvoiceSaving(false);
|
setInvoiceSaving(false);
|
||||||
setInvoiceForm({
|
setInvoiceForm({
|
||||||
naziv_racuna: '',
|
naziv_racuna: '',
|
||||||
@@ -811,14 +814,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
Izmijeni
|
Izmijeni
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={submit}
|
type="button"
|
||||||
disabled={submitting}
|
onClick={() => setPhotoUploadOpen(true)}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||||
>
|
>
|
||||||
{submitting ? 'Spremam...' : 'Spremi izmjene'}
|
📷 Dodaj fotografije
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? 'Spremam...' : 'Spremi izmjene'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1110,6 +1122,20 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
</div>
|
</div>
|
||||||
</ModalShell>
|
</ModalShell>
|
||||||
)}
|
)}
|
||||||
|
<WorkOrderPhotoUpload
|
||||||
|
open={photoUploadOpen}
|
||||||
|
workOrderId={workOrder?.id}
|
||||||
|
onClose={() => setPhotoUploadOpen(false)}
|
||||||
|
onBack={() => setPhotoUploadOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setPhotoUploadOpen(false);
|
||||||
|
if (workOrder?.id) {
|
||||||
|
fetchWorkOrderImages(workOrder.id, { w: 1280, q: 75, fmt: 'webp' })
|
||||||
|
.then((files) => setImageFiles(files.map((item) => item.optimized_url || item.original_url).filter(Boolean)))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
247
frontend/src/components/fleet/WorkOrderPhotoUpload.jsx
Normal file
247
frontend/src/components/fleet/WorkOrderPhotoUpload.jsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
|
import { useStore } from '@nanostores/preact';
|
||||||
|
import ModalShell from '../ui/ModalShell';
|
||||||
|
import { $isOffline } from '../../stores/networkStore.js';
|
||||||
|
import { uploadWorkOrderPhoto } from '../../stores/fleetDashboardStore.js';
|
||||||
|
import { showToast } from '../../stores/toastStore.js';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_MB = 10;
|
||||||
|
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||||
|
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
|
||||||
|
|
||||||
|
export default function WorkOrderPhotoUpload({ open, workOrderId, onClose, onSuccess, onBack }) {
|
||||||
|
const isOffline = useStore($isOffline);
|
||||||
|
const [images, setImages] = useState([]);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const abortRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
setImages([]);
|
||||||
|
setDescription('');
|
||||||
|
setError('');
|
||||||
|
setSubmitting(false);
|
||||||
|
setProgress({ current: 0, total: 0 });
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open || !isMounted) return null;
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const files = Array.from(e.target.files ?? []);
|
||||||
|
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES);
|
||||||
|
const invalidType = files.filter((f) => !ACCEPTED_TYPES.includes(f.type));
|
||||||
|
|
||||||
|
if (oversized.length > 0) {
|
||||||
|
setError(`Sljedeće datoteke prelaze ${MAX_FILE_SIZE_MB} MB: ${oversized.map((f) => f.name).join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (invalidType.length > 0) {
|
||||||
|
setError(`Nepodržan format: ${invalidType.map((f) => f.name).join(', ')}. Koristite JPG, PNG, WEBP ili HEIC.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setImages(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!workOrderId) {
|
||||||
|
setError('Nije poznat putni nalog za koji se upload vrši.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (images.length === 0) {
|
||||||
|
setError('Odaberite barem jednu fotografiju.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isOffline) {
|
||||||
|
showToast('Upload fotografija nije dostupan u offline modu.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
const { signal } = abortRef.current;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setProgress({ current: 0, total: images.length });
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
try {
|
||||||
|
for (const [index, file] of images.entries()) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
setProgress({ current: index + 1, total: images.length });
|
||||||
|
await uploadWorkOrderPhoto(workOrderId, file, description, signal);
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signal.aborted) {
|
||||||
|
showToast(`${successCount} fotografija putnog naloga je uspješno uploadano.`, 'success');
|
||||||
|
e.target.reset();
|
||||||
|
setImages([]);
|
||||||
|
setDescription('');
|
||||||
|
onSuccess?.();
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err?.message || 'Greška pri uploadu fotografija putnog naloga.');
|
||||||
|
if (successCount > 0) {
|
||||||
|
showToast(`${successCount} od ${images.length} fotografija uploadano.`, 'warning');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) {
|
||||||
|
setSubmitting(false);
|
||||||
|
setProgress({ current: 0, total: 0 });
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
onClose={handleCancel}
|
||||||
|
overlayClassName="z-[60] p-4"
|
||||||
|
contentClassName="flex items-center justify-center min-h-full"
|
||||||
|
panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||||
|
aria-label="Natrag"
|
||||||
|
>
|
||||||
|
← Natrag
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-text-main">Upload fotografija putnog naloga</h3>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
|
Putni nalog #{workOrderId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||||
|
aria-label="Zatvori"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted uppercase tracking-wide">
|
||||||
|
Fotografije putnog naloga ({images.length} odabrano)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_TYPES.join(',')}
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={submitting}
|
||||||
|
className="block w-full text-xs text-text-muted file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-mono file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 file:cursor-pointer transition-all disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-text-muted">
|
||||||
|
JPG, PNG, WEBP, HEIC — max {MAX_FILE_SIZE_MB} MB po datoteci
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-border-hairline bg-canvas-deep p-2 max-h-32 overflow-y-auto space-y-1">
|
||||||
|
{images.map((file, i) => (
|
||||||
|
<p key={i} className="text-[10px] font-mono text-text-muted truncate">
|
||||||
|
📎 [{i + 1}] {file.name}{' '}
|
||||||
|
<span className="text-text-muted/60">
|
||||||
|
({(file.size / 1024).toFixed(1)} KB)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Opis (primjenjuje se na sve slike)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onInput={(e) => setDescription(e.currentTarget.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="Npr. Slike stanja na lokaciji prije/poslije intervencije"
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{submitting && progress.total > 1 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-[11px] text-text-muted mb-1">
|
||||||
|
<span>Uploading...</span>
|
||||||
|
<span>{progress.current}/{progress.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-canvas-deep overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
|
||||||
|
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
{submitting ? 'Prekini upload' : 'Odustani'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || images.length === 0 || isOffline}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? `Uploading ${progress.current}/${progress.total}...`
|
||||||
|
: `Upload ${images.length > 0 ? images.length + ' slike' : 'fotografija'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -607,6 +607,17 @@ export async function uploadServicePhoto(serviceRecordId, imageFile, description
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadWorkOrderPhoto(workOrderId, imageFile, description = '', signal = null) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', imageFile);
|
||||||
|
if (description.trim()) {
|
||||||
|
formData.append('description', description.trim());
|
||||||
|
}
|
||||||
|
const options = signal ? { signal } : {};
|
||||||
|
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/images/`, formData, options);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadServiceAttachment(serviceRecordId, file, description = '', signal = null) {
|
export async function uploadServiceAttachment(serviceRecordId, file, description = '', signal = null) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('service_record', serviceRecordId);
|
formData.append('service_record', serviceRecordId);
|
||||||
|
|||||||
Reference in New Issue
Block a user