diff --git a/backend/core/users/import_templates/users_import_template.csv b/backend/core/users/import_templates/users_import_template.csv index 4f803a2..1caa8a3 100644 --- a/backend/core/users/import_templates/users_import_template.csv +++ b/backend/core/users/import_templates/users_import_template.csv @@ -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,, 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,, +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,, \ No newline at end of file diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py index 8bb9864..7d5b4e1 100644 --- a/backend/modules/fleet/admin.py +++ b/backend/modules/fleet/admin.py @@ -5,6 +5,7 @@ from .models import ( Crane, Vehicle, WorkOrder, + WorkOrderPhoto, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, @@ -60,6 +61,26 @@ class WorkOrderInvoiceInline(admin.TabularInline): 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( + '' + '' + '', + obj.image.url, + obj.image.url, + ) + return "—" + + @admin.register(Vehicle) class VehicleAdmin(admin.ModelAdmin): list_display = ( @@ -159,7 +180,7 @@ class WorkOrderAdmin(admin.ModelAdmin): search_fields = ('vehicle__registration_number', 'creator__username', 'purpose') readonly_fields = ('date',) ordering = ('-date',) - inlines = (WorkOrderInvoiceInline,) + inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline) def distance_display(self, obj): return obj.distance diff --git a/backend/modules/fleet/migrations/0022_workorderphoto.py b/backend/modules/fleet/migrations/0022_workorderphoto.py new file mode 100644 index 0000000..2345915 --- /dev/null +++ b/backend/modules/fleet/migrations/0022_workorderphoto.py @@ -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'], + }, + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index 0bae765..1541680 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -45,6 +45,15 @@ def _work_order_invoice_upload_to(instance, filename): unique_name = f"{uuid.uuid4().hex}_{filename}" 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): """ @@ -344,6 +353,37 @@ class WorkOrderInvoice(BaseModel): 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): """ Zapisi o servisima/popravcima na vozilu. diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py index a068f65..8915e11 100644 --- a/backend/modules/fleet/serializers.py +++ b/backend/modules/fleet/serializers.py @@ -5,6 +5,7 @@ from .models import ( Vehicle, VehicleNotification, WorkOrder, + WorkOrderPhoto, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, @@ -198,6 +199,16 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer): 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): crane = serializers.SerializerMethodField() crane_registration = serializers.SerializerMethodField() diff --git a/backend/modules/fleet/tests/test_work_order_images_endpoint.py b/backend/modules/fleet/tests/test_work_order_images_endpoint.py new file mode 100644 index 0000000..9fadd85 --- /dev/null +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -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) diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 30867a2..ffe974c 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -30,6 +30,7 @@ from .models import ( Vehicle, VehicleNotification, WorkOrder, + WorkOrderPhoto, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, @@ -40,6 +41,7 @@ from .serializers import ( VehicleSerializer, WorkOrderSerializer, WorkOrderInvoiceSerializer, + WorkOrderPhotoSerializer, VehicleServiceRecordSerializer, VehicleNotificationSerializer, VehicleServicePhotoSerializer, @@ -1240,10 +1242,15 @@ class WorkOrderViewSet(viewsets.ModelViewSet): def get_queryset(self): 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): 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( request.query_params.get('q'), field_name='q', @@ -1259,60 +1266,29 @@ class WorkOrderViewSet(viewsets.ModelViewSet): max_value=3840, ) image_format, _ = _parse_format(request.query_params.get('fmt')) - work_order_task_ids = list( - 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( + photos_qs = WorkOrderPhoto.objects.filter( is_active=True, - service_record__is_active=True, - service_record__vehicle_id=work_order.vehicle_id, - 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) + work_order_id=work_order.pk, + ).select_related('uploaded_by').order_by('-created_at') images = [] 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 images.append({ "id": photo.pk, - "service_record_id": photo.service_record_id, + "work_order_id": photo.work_order_id, "description": photo.description, "original_url": original_url, - "optimized_url": optimized_url, + "optimized_url": original_url, "created_at": photo.created_at, }) return Response({ "work_order_id": work_order.pk, "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, }, status=status.HTTP_200_OK) diff --git a/frontend/src/components/dashboard/FleetDashboardShell.jsx b/frontend/src/components/dashboard/FleetDashboardShell.jsx index bf17be7..2003c61 100644 --- a/frontend/src/components/dashboard/FleetDashboardShell.jsx +++ b/frontend/src/components/dashboard/FleetDashboardShell.jsx @@ -9,6 +9,7 @@ import WorkOrderDetailModal from './WorkOrderDetailModal'; import SyncLogPanel from './SyncLogPanel'; import ServiceRecordForm from '../fleet/ServiceRecordForm'; import ServicePhotoUpload from '../fleet/ServicePhotoUpload'; +import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload'; import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal'; import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton'; import ClientsSection from '../crm/ClientsSection'; @@ -93,6 +94,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page const [selectedServiceRecord, setSelectedServiceRecord] = useState(null); // photoUpload: { open: bool, serviceRecordId: string|null } const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null }); + const [workOrderPhotoUpload, setWorkOrderPhotoUpload] = useState({ open: false, workOrderId: null }); const loading = useStore($dashboardLoading); const error = useStore($dashboardError); const stats = useStore($dashboardStats); @@ -492,6 +494,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page const workOrderColumns = [ { key: 'code', label: 'Putni nalog', 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: 'status', label: 'Status', 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?.crane_serial_number || '-'} + {formatDate(workOrder.date)} @@ -773,15 +779,10 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page @@ -913,7 +914,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page {JSON.stringify(group, null, 2)} */} {formatDate(record.service_date)} - {record.crane_serial_number || record.vehicle_registration || '-'} + {record.crane_serial_number || '-'} {record.description || '-'} @@ -1094,6 +1095,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page onSuccess={() => fetchFleetDashboardData({ silent: true })} /> + setWorkOrderPhotoUpload({ open: false, workOrderId: null })} + onSuccess={() => fetchFleetDashboardData({ silent: true })} + /> + ) : ( - + <> + + + )} @@ -1110,6 +1122,20 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi )} + 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(() => {}); + } + }} + /> ); } diff --git a/frontend/src/components/fleet/WorkOrderPhotoUpload.jsx b/frontend/src/components/fleet/WorkOrderPhotoUpload.jsx new file mode 100644 index 0000000..1c4d0f6 --- /dev/null +++ b/frontend/src/components/fleet/WorkOrderPhotoUpload.jsx @@ -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 ( + +
+
+
+ {onBack && ( + + )} +
+

Upload fotografija putnog naloga

+

+ Putni nalog #{workOrderId} +

+
+
+ +
+ +
+
+ + +

+ JPG, PNG, WEBP, HEIC — max {MAX_FILE_SIZE_MB} MB po datoteci +

+
+ + {images.length > 0 && ( +
+ {images.map((file, i) => ( +

+ 📎 [{i + 1}] {file.name}{' '} + + ({(file.size / 1024).toFixed(1)} KB) + +

+ ))} +
+ )} + + + + {submitting && progress.total > 1 && ( +
+
+ Uploading... + {progress.current}/{progress.total} +
+
+
+
+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ + ); +} diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index a651a0a..75c3aa3 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -607,6 +607,17 @@ export async function uploadServicePhoto(serviceRecordId, imageFile, description 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) { const formData = new FormData(); formData.append('service_record', serviceRecordId);