diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py index cdc5972..254ffe1 100644 --- a/backend/modules/fleet/admin.py +++ b/backend/modules/fleet/admin.py @@ -10,6 +10,7 @@ from .models import ( WorkOrderAdditionalCostsTable, ServiceContextNote, GeneratedWorkOrderPdf, + EmailDispatchLog, VehicleServiceRecord, VehicleServicePhoto, VehicleServiceAttachment, @@ -244,4 +245,50 @@ class VehicleServiceRecordAdmin(admin.ModelAdmin): def short_description(self, obj): if obj.description: return obj.description[:60] + ('…' if len(obj.description) > 60 else '') - return "—" \ No newline at end of file + return "—" + + +@admin.register(EmailDispatchLog) +class EmailDispatchLogAdmin(admin.ModelAdmin): + list_display = ( + 'id', + 'dispatch_type', + 'status', + 'requested_by', + 'work_order', + 'service_record', + 'recipients_count', + 'sent_at', + 'created_at', + ) + list_filter = ('dispatch_type', 'status', 'created_at', 'sent_at') + search_fields = ( + 'subject', + 'message', + 'error_message', + 'requested_by__email', + 'requested_by__username', + 'work_order__display_code', + ) + readonly_fields = ( + 'dispatch_type', + 'requested_by', + 'work_order', + 'service_record', + 'recipients', + 'subject', + 'message', + 'attachments', + 'status', + 'error_message', + 'metadata', + 'sent_at', + 'created_at', + 'updated_at', + ) + ordering = ('-created_at',) + + @admin.display(description='Primatelja') + def recipients_count(self, obj): + recipients = obj.recipients or [] + return len(recipients) if isinstance(recipients, list) else 0 \ No newline at end of file diff --git a/backend/modules/fleet/migrations/0033_emaildispatchlog.py b/backend/modules/fleet/migrations/0033_emaildispatchlog.py new file mode 100644 index 0000000..7ddc6f4 --- /dev/null +++ b/backend/modules/fleet/migrations/0033_emaildispatchlog.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.15 on 2026-07-21 11:50 + +import uuid +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0032_servicecontextnote'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EmailDispatchLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('dispatch_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_record', 'Servisni zapis')], db_index=True, max_length=32, verbose_name='Tip slanja')), + ('recipients', models.JSONField(blank=True, default=list, verbose_name='Primatelji')), + ('subject', models.CharField(blank=True, max_length=255, verbose_name='Naslov')), + ('message', models.TextField(blank=True, verbose_name='Poruka')), + ('attachments', models.JSONField(blank=True, default=list, verbose_name='Privitci')), + ('status', models.CharField(choices=[('queued', 'U obradi'), ('sent', 'Poslano'), ('failed', 'Neuspješno')], db_index=True, default='queued', max_length=16, verbose_name='Status')), + ('error_message', models.TextField(blank=True, verbose_name='Greška')), + ('metadata', models.JSONField(blank=True, default=dict, verbose_name='Metapodaci')), + ('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='Poslano u')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_email_dispatch_logs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')), + ('service_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')), + ('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.workorder', verbose_name='Putni nalog')), + ], + options={ + 'verbose_name': 'Log slanja emaila', + 'verbose_name_plural': 'Logovi slanja emaila', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index a050246..6afeb86 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -568,6 +568,67 @@ class VehicleNotification(BaseModel): return f"{self.title} -> {self.recipient} ({self.level})" +class EmailDispatchLog(BaseModel): + DISPATCH_TYPE_CHOICES = [ + ('work_order', _("Putni nalog")), + ('service_record', _("Servisni zapis")), + ] + STATUS_CHOICES = [ + ('queued', _("U obradi")), + ('sent', _("Poslano")), + ('failed', _("Neuspješno")), + ] + + dispatch_type = models.CharField(max_length=32, choices=DISPATCH_TYPE_CHOICES, db_index=True, verbose_name=_("Tip slanja")) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='requested_email_dispatch_logs', + verbose_name=_("Zatražio"), + ) + work_order = models.ForeignKey( + 'fleet.WorkOrder', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='email_dispatch_logs', + verbose_name=_("Putni nalog"), + ) + service_record = models.ForeignKey( + 'fleet.VehicleServiceRecord', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='email_dispatch_logs', + verbose_name=_("Servisni zapis"), + ) + recipients = models.JSONField(default=list, blank=True, verbose_name=_("Primatelji")) + subject = models.CharField(max_length=255, blank=True, verbose_name=_("Naslov")) + message = models.TextField(blank=True, verbose_name=_("Poruka")) + attachments = models.JSONField(default=list, blank=True, verbose_name=_("Privitci")) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='queued', db_index=True, verbose_name=_("Status")) + error_message = models.TextField(blank=True, verbose_name=_("Greška")) + metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci")) + sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Poslano u")) + + class Meta: + verbose_name = _("Log slanja emaila") + verbose_name_plural = _("Logovi slanja emaila") + ordering = ['-created_at'] + + def __str__(self): + return f"{self.dispatch_type} -> {len(self.recipients or [])} ({self.status})" + + def clean(self): + super().clean() + if self.dispatch_type == 'work_order' and not self.work_order_id: + raise ValidationError({'work_order': _("Putni nalog je obavezan za ovaj tip slanja.")}) + if self.dispatch_type == 'service_record' and not self.service_record_id: + raise ValidationError({'service_record': _("Servisni zapis je obavezan za ovaj tip slanja.")}) + + class ServiceContextNote(BaseModel): AUDIENCE_SOURCE_CHOICES = [ ('self', 'Samo autor'), diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index 517aeb1..8f0a0cb 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -1,15 +1,23 @@ # backend/modules/fleet/tasks.py from celery import shared_task from django.core.mail import send_mail +from django.core.mail import EmailMessage from django.conf import settings from django.utils import timezone from django.core.files.base import ContentFile import logging from io import BytesIO +from io import StringIO from pathlib import Path import base64 import re +import csv +import mimetypes from decimal import Decimal, InvalidOperation +from smtplib import SMTPSenderRefused +from datetime import timedelta +from django.core.exceptions import ValidationError as DjangoValidationError +from django.core.validators import validate_email from PIL import Image, UnidentifiedImageError from django.db import transaction from pypdf import PdfReader @@ -93,6 +101,450 @@ def send_email_task(recipient_email, subject, message, notif_id): logger.error(f"Async mail failed: {exc}") +def _guess_content_type(filename): + mime, _ = mimetypes.guess_type(str(filename or '')) + return mime or 'application/octet-stream' + + +def _file_attachment(file_field, fallback_name): + if not file_field: + return None + file_field.open('rb') + try: + content = file_field.read() + finally: + file_field.close() + if not content: + return None + filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name + return (filename, content, _guess_content_type(filename)) + + +def _build_image_attachments_for_work_order(work_order): + from .models import WorkOrderPhoto, WorkOrderInvoice + + attachments = [] + photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at') + for index, photo in enumerate(photos, start=1): + attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin") + if attachment: + attachments.append(attachment) + + invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at') + for index, invoice in enumerate(invoices, start=1): + attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin") + if attachment: + attachments.append(attachment) + return attachments + + +def _user_display_name(user): + if not user: + return None + full_name = user.get_full_name() + if full_name: + return full_name + if getattr(user, 'email', None): + return user.email + if getattr(user, 'username', None): + return user.username + return str(getattr(user, 'pk', '')) + + +def _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key): + from modules.task_management.models import Task + + tasks = ( + Task.objects + .filter( + is_active=True, + vehicle_id=work_order.vehicle_id, + scheduled_date__year=year, + scheduled_date__month=month, + ) + .select_related('assigned_to', 'work_order') + .order_by('scheduled_date', 'created_at') + ) + + csv_buffer = StringIO() + writer = csv.writer(csv_buffer) + writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis']) + for task in tasks: + assignee_name = _user_display_name(task.assigned_to) or '-' + work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-' + writer.writerow([ + task.scheduled_date.isoformat() if task.scheduled_date else '', + task.title or '', + task.status or '', + assignee_name, + work_order_label, + task.description or '', + ]) + csv_content = csv_buffer.getvalue().encode('utf-8') + filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv" + return (filename, csv_content, 'text/csv'), tasks.count() + + +def _resolve_from_email(): + candidates = [ + getattr(settings, 'DEFAULT_FROM_EMAIL', None), + getattr(settings, 'EMAIL_HOST_USER', None), + ] + for candidate in candidates: + if not candidate: + continue + normalized = str(candidate).strip() + if not normalized: + continue + try: + validate_email(normalized) + return normalized + except DjangoValidationError: + continue + return None + + +def _detect_generated_pdf_type(filename): + normalized = str(filename or '').strip().lower() + if normalized.endswith('.work-order-invoices.pdf'): + return 'invoices' + if normalized.endswith('.work-order-service-records.pdf'): + return 'service_records' + if normalized.endswith('.work-order.pdf'): + return 'work_order' + return None + + +def _cache_pdf_attachments_for_download(*, work_order, requested_by, attachments): + cached_payload = [] + ttl_hours = int(getattr(settings, 'GENERATED_PDF_TTL_HOURS', 24)) + expires_at = timezone.now() + timedelta(hours=ttl_hours) + + for filename, content, content_type in attachments: + if str(content_type or '').lower() != 'application/pdf': + continue + pdf_type = _detect_generated_pdf_type(filename) + if not pdf_type: + continue + generated = GeneratedWorkOrderPdf.objects.create( + work_order=work_order, + requested_by=requested_by, + pdf_type=pdf_type, + status='ready', + filename=filename, + expires_at=expires_at, + ) + generated.file.save(filename, ContentFile(content), save=False) + generated.save(update_fields=['file', 'updated_at']) + cached_payload.append( + { + 'generated_pdf_id': str(generated.pk), + 'pdf_type': pdf_type, + 'filename': filename, + 'download_url': f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/", + 'expires_at': expires_at.isoformat(), + } + ) + return cached_payload + + +@shared_task +def send_work_order_email_bundle_task( + *, + work_order_id, + requested_by_id, + recipients, + subject='', + message='', + include_work_order_pdf=True, + include_service_records_pdf=False, + include_invoices_pdf=False, + include_images=False, + include_monthly_tasks=False, + month_year=None, + month_number=None, + month_key='', + service_note_id=None, + dispatch_log_id=None, +): + from django.contrib.auth import get_user_model + from .models import WorkOrder, ServiceContextNote, EmailDispatchLog + from .services import NotificationService + from .views import _build_work_order_pdf, _build_work_order_service_records_pdf + + User = get_user_model() + requested_by = User.objects.filter(pk=requested_by_id, is_active=True).first() + dispatch_log = EmailDispatchLog.objects.filter(pk=dispatch_log_id).first() if dispatch_log_id else None + work_order = WorkOrder.objects.select_related('vehicle', 'creator', 'vehicle__client').filter(pk=work_order_id).first() + if work_order is None: + if dispatch_log: + dispatch_log.status = 'failed' + dispatch_log.error_message = 'Putni nalog nije pronađen.' + dispatch_log.save(update_fields=['status', 'error_message', 'updated_at']) + if requested_by: + NotificationService.create_notification( + recipient=requested_by, + title='Greška kod slanja emaila', + message='Putni nalog za slanje emaila nije pronađen.', + level='warning', + send_email=False, + metadata={ + 'entity_type': 'work_order_email', + 'stage': 'failed', + 'work_order_id': str(work_order_id), + }, + ) + return {'status': 'failed', 'error': 'work-order-not-found'} + + body_lines = [str(message or 'U prilogu je dokumentacija putnog naloga.')] + if service_note_id: + note = ServiceContextNote.objects.select_related('created_by').filter( + pk=service_note_id, + is_active=True, + ).first() + if note: + creator_name = _user_display_name(note.created_by) or '-' + note_date = note.note_date.isoformat() if note.note_date else '-' + body_lines.append("") + body_lines.append("Odabrana bilješka servisnog konteksta:") + body_lines.append(f"- Autor: {creator_name}") + body_lines.append(f"- Datum bilješke: {note_date}") + body_lines.append(f"- Tekst: {note.note}") + + attachments = [] + if include_work_order_pdf: + attachments.append(( + f"{_work_order_display_code(work_order)}.work-order.pdf", + _build_work_order_pdf(work_order), + 'application/pdf', + )) + if include_service_records_pdf: + attachments.append(( + f"{_work_order_display_code(work_order)}.work-order-service-records.pdf", + _build_work_order_service_records_pdf(work_order), + 'application/pdf', + )) + if include_invoices_pdf: + attachments.append(( + f"{_work_order_display_code(work_order)}.work-order-invoices.pdf", + _build_work_order_invoices_pdf(work_order), + 'application/pdf', + )) + if include_images: + image_attachments = _build_image_attachments_for_work_order(work_order) + attachments.extend(image_attachments) + body_lines.append("") + body_lines.append(f"Dodano slika/datoteka: {len(image_attachments)}.") + monthly_tasks_count = 0 + if include_monthly_tasks and month_year and month_number and month_key: + monthly_attachment, monthly_tasks_count = _build_monthly_tasks_csv_attachment( + work_order, + year=month_year, + month=month_number, + month_key=month_key, + ) + attachments.append(monthly_attachment) + body_lines.append("") + body_lines.append(f"Priložen je popis mjesečnih radnih taskova za {month_key} (ukupno: {monthly_tasks_count}).") + + cached_pdf_payload = [] + if attachments: + cached_pdf_payload = _cache_pdf_attachments_for_download( + work_order=work_order, + requested_by=requested_by, + attachments=attachments, + ) + + from_email = _resolve_from_email() + if not from_email: + if dispatch_log: + dispatch_log.status = 'failed' + dispatch_log.error_message = 'SMTP pošiljatelj nije valjan.' + dispatch_log.metadata = { + **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), + 'cached_pdfs': cached_pdf_payload, + } + dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at']) + if requested_by: + NotificationService.create_notification( + recipient=requested_by, + title='Greška kod slanja emaila', + message='SMTP pošiljatelj nije valjan. Provjerite DEFAULT_FROM_EMAIL/EMAIL_HOST_USER postavke.', + level='warning', + send_email=False, + metadata={ + 'entity_type': 'work_order_email', + 'stage': 'failed', + 'work_order_id': str(work_order.pk), + 'cached_pdfs': cached_pdf_payload, + }, + ) + return {'status': 'failed', 'error': 'invalid-from-email'} + + total_attachment_bytes = sum(len(content) for _, content, _ in attachments) + max_attachment_bytes = int(getattr(settings, 'WORK_ORDER_EMAIL_MAX_ATTACHMENT_BYTES', 17 * 1024 * 1024)) + + if total_attachment_bytes > max_attachment_bytes: + if dispatch_log: + dispatch_log.status = 'failed' + dispatch_log.error_message = 'Ukupna veličina privitaka je prevelika.' + dispatch_log.metadata = { + **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), + 'attachments_bytes': total_attachment_bytes, + 'max_attachments_bytes': max_attachment_bytes, + 'cached_pdfs': cached_pdf_payload, + } + dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at']) + if requested_by: + NotificationService.create_notification( + recipient=requested_by, + title='Email nije poslan — preveliki privitci', + message='Ukupna veličina privitaka je prevelika za slanje. Smanjite broj/veličinu privitaka.', + level='warning', + send_email=False, + metadata={ + 'entity_type': 'work_order_email', + 'stage': 'failed', + 'work_order_id': str(work_order.pk), + 'attachments_bytes': total_attachment_bytes, + 'max_attachments_bytes': max_attachment_bytes, + 'cached_pdfs': cached_pdf_payload, + }, + ) + return {'status': 'failed', 'error': 'attachments-too-large'} + + if not attachments: + if dispatch_log: + dispatch_log.status = 'failed' + dispatch_log.error_message = 'Nije odabran nijedan privitak.' + dispatch_log.save(update_fields=['status', 'error_message', 'updated_at']) + if requested_by: + NotificationService.create_notification( + recipient=requested_by, + title='Greška kod slanja emaila', + message='Nije odabran nijedan PDF/prilog za slanje.', + level='warning', + send_email=False, + metadata={ + 'entity_type': 'work_order_email', + 'stage': 'failed', + 'work_order_id': str(work_order_id), + }, + ) + return {'status': 'failed', 'error': 'no-attachments'} + + email_message = EmailMessage( + subject=subject or f"Putni nalog {_work_order_display_code(work_order)}", + body="\n".join(body_lines).strip(), + from_email=from_email, + to=recipients or [], + ) + for filename, content, content_type in attachments: + email_message.attach(filename, content, content_type) + if dispatch_log: + dispatch_log.attachments = [ + { + 'filename': filename, + 'content_type': content_type, + 'size_bytes': len(content), + } + for filename, content, content_type in attachments + ] + dispatch_log.save(update_fields=['attachments', 'updated_at']) + + try: + email_message.send(fail_silently=False) + except SMTPSenderRefused as exc: + logger.exception("SMTP sender refused for work order email bundle: %s", exc) + if dispatch_log: + dispatch_log.status = 'failed' + dispatch_log.error_message = f'SMTP sender refused ({exc.smtp_code}).' + dispatch_log.metadata = { + **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), + 'smtp_code': exc.smtp_code, + 'from_email': from_email, + 'cached_pdfs': cached_pdf_payload, + } + dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at']) + if requested_by: + NotificationService.create_notification( + recipient=requested_by, + title='Greška kod slanja emaila', + message='SMTP server je odbio pošiljatelja. Provjerite email konfiguraciju.', + level='warning', + send_email=False, + metadata={ + 'entity_type': 'work_order_email', + 'stage': 'failed', + 'work_order_id': str(work_order.pk), + 'smtp_code': exc.smtp_code, + 'from_email': from_email, + 'cached_pdfs': cached_pdf_payload, + }, + ) + return {'status': 'failed', 'error': 'smtp-sender-refused'} + except Exception as exc: + logger.exception("Greška kod slanja work order email bundle: %s", exc) + if dispatch_log: + dispatch_log.status = 'failed' + dispatch_log.error_message = str(exc) + dispatch_log.metadata = { + **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), + 'cached_pdfs': cached_pdf_payload, + } + dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at']) + if requested_by: + NotificationService.create_notification( + recipient=requested_by, + title='Greška kod slanja emaila', + message=f'Slanje emaila za putni nalog {_work_order_display_code(work_order)} nije uspjelo.', + level='warning', + send_email=False, + metadata={ + 'entity_type': 'work_order_email', + 'stage': 'failed', + 'work_order_id': str(work_order.pk), + 'recipients_count': len(recipients or []), + 'cached_pdfs': cached_pdf_payload, + }, + ) + return {'status': 'failed', 'error': str(exc)} + + if requested_by: + NotificationService.create_notification( + recipient=requested_by, + title='Email uspješno poslan', + message=f'Email za putni nalog {_work_order_display_code(work_order)} je uspješno poslan.', + level='success', + send_email=False, + metadata={ + 'entity_type': 'work_order_email', + 'stage': 'completed', + 'work_order_id': str(work_order.pk), + 'recipients_count': len(recipients or []), + 'attachments_count': len(attachments), + 'monthly_tasks_count': monthly_tasks_count, + }, + ) + if dispatch_log: + dispatch_log.status = 'sent' + dispatch_log.error_message = '' + dispatch_log.sent_at = timezone.now() + dispatch_log.metadata = { + **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), + 'recipients_count': len(recipients or []), + 'attachments_count': len(attachments), + 'monthly_tasks_count': monthly_tasks_count, + } + dispatch_log.save(update_fields=['status', 'error_message', 'sent_at', 'metadata', 'updated_at']) + return { + 'status': 'ok', + 'work_order_id': str(work_order.pk), + 'recipients_count': len(recipients or []), + 'attachments_count': len(attachments), + } + + def _build_work_order_invoices_pdf(work_order): buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) diff --git a/backend/modules/fleet/tests/test_work_order_email_task_cached_pdfs.py b/backend/modules/fleet/tests/test_work_order_email_task_cached_pdfs.py new file mode 100644 index 0000000..74d2721 --- /dev/null +++ b/backend/modules/fleet/tests/test_work_order_email_task_cached_pdfs.py @@ -0,0 +1,60 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings + +from modules.fleet.models import GeneratedWorkOrderPdf, Vehicle, VehicleNotification, WorkOrder +from modules.fleet.tasks import send_work_order_email_bundle_task + + +@override_settings( + EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', + DEFAULT_FROM_EMAIL='no-reply@example.test', +) +class WorkOrderEmailTaskCachedPdfTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='task-mail-user', + email='task-mail-user@example.test', + password='test1234', + ) + self.vehicle = Vehicle.objects.create( + asset_type='crane', + registration_number='TASK-CACHE-001', + crane_serial_number='112233', + ) + self.work_order = WorkOrder.objects.create( + vehicle=self.vehicle, + creator=self.user, + display_code='MT010126', + purpose='kontrola', + ) + + @patch('django.core.mail.message.EmailMessage.send', side_effect=Exception('smtp-fail')) + def test_failed_email_creates_cached_pdf_links_in_notification(self, _mock_send): + result = send_work_order_email_bundle_task( + work_order_id=str(self.work_order.pk), + requested_by_id=str(self.user.pk), + recipients=['client@example.test'], + include_work_order_pdf=True, + include_service_records_pdf=False, + include_invoices_pdf=False, + include_images=False, + include_monthly_tasks=False, + ) + + self.assertEqual(result.get('status'), 'failed') + + cached = GeneratedWorkOrderPdf.objects.filter(work_order=self.work_order, pdf_type='work_order', status='ready') + self.assertTrue(cached.exists()) + + notif = VehicleNotification.objects.filter( + recipient=self.user, + metadata__entity_type='work_order_email', + metadata__stage='failed', + ).order_by('-created_at').first() + self.assertIsNotNone(notif) + cached_pdfs = notif.metadata.get('cached_pdfs') if isinstance(notif.metadata, dict) else None + self.assertTrue(isinstance(cached_pdfs, list) and len(cached_pdfs) > 0) + self.assertIn('download_url', cached_pdfs[0]) diff --git a/backend/modules/fleet/tests/test_work_order_send_email.py b/backend/modules/fleet/tests/test_work_order_send_email.py new file mode 100644 index 0000000..dcdb687 --- /dev/null +++ b/backend/modules/fleet/tests/test_work_order_send_email.py @@ -0,0 +1,128 @@ +import uuid +from datetime import date +from io import BytesIO + +from PIL import Image +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient +from unittest.mock import patch + +from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder, WorkOrderInvoice, WorkOrderPhoto +from modules.task_management.models import Task + + +def _test_image(filename='test.jpg', size=(40, 40), color='blue'): + 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', + ) + + +@override_settings( + EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', + DEFAULT_FROM_EMAIL='no-reply@example.test', +) +class WorkOrderSendEmailTests(TestCase): + def setUp(self): + user_model = get_user_model() + suffix = uuid.uuid4().hex[:8] + self.user = user_model.objects.create_user( + username=f'wo-mail-user-{suffix}', + email=f'wo-mail-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-MAIL-001', + crane_serial_number='654321', + ) + self.work_order = WorkOrder.objects.create( + vehicle=self.vehicle, + creator=self.user, + display_code='MT010126', + purpose='kontrola', + ) + + def test_send_email_supports_multiple_recipients_and_all_requested_attachments(self): + WorkOrderPhoto.objects.create( + work_order=self.work_order, + image=_test_image('work-order-photo.jpg'), + description='Work order image', + uploaded_by=self.user, + ) + WorkOrderInvoice.objects.create( + work_order=self.work_order, + naziv_racuna='Račun 1', + image=_test_image('invoice-image.jpg'), + created_by=self.user, + ) + note = ServiceContextNote.objects.create( + created_by=self.user, + recipient=self.user, + audience_source='self', + work_order=self.work_order, + note='Odabrana test bilješka.', + ) + Task.objects.create( + title='Mjesečni task', + description='Provjera mjesečnog task popisa.', + assigned_to=self.user, + vehicle=self.vehicle, + work_order=self.work_order, + scheduled_date=date(2026, 7, 10), + ) + + with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay: + response = self.client.post( + reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}), + data={ + 'recipients': ['client@example.test', 'supervisor@example.test'], + 'subject': 'Test slanje dokumentacije', + 'message': 'U privitku su svi traženi dokumenti.', + 'include_work_order_pdf': True, + 'include_service_records_pdf': True, + 'include_invoices_pdf': True, + 'include_images': True, + 'include_monthly_tasks': True, + 'month': '2026-07', + 'service_note_id': str(note.pk), + }, + format='json', + ) + + self.assertEqual(response.status_code, 202, response.data) + self.assertTrue(response.data.get('queued')) + self.assertEqual(len(response.data.get('recipients', [])), 2) + mocked_delay.assert_called_once() + call_kwargs = mocked_delay.call_args.kwargs + self.assertEqual(call_kwargs.get('work_order_id'), str(self.work_order.pk)) + self.assertEqual(call_kwargs.get('requested_by_id'), str(self.user.id)) + self.assertEqual(call_kwargs.get('month_key'), '2026-07') + self.assertEqual(call_kwargs.get('service_note_id'), str(note.pk)) + + def test_send_email_keeps_backward_compatible_single_recipient_payload(self): + with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay: + response = self.client.post( + reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}), + data={ + 'recipient': 'single@example.test', + 'subject': 'Legacy send', + }, + format='json', + ) + + self.assertEqual(response.status_code, 202, response.data) + self.assertTrue(response.data.get('queued')) + self.assertEqual(response.data.get('recipients'), ['single@example.test']) + mocked_delay.assert_called_once() diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 99d685e..656503d 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -1,7 +1,11 @@ # /backend/modules/fleet/views.py from io import BytesIO +from io import StringIO import base64 +import csv +import mimetypes +import threading from collections import OrderedDict from datetime import timedelta from pathlib import Path @@ -11,6 +15,8 @@ from django.contrib.auth import get_user_model from PIL import Image, UnidentifiedImageError from django.conf import settings from django.core.mail import EmailMessage +from django.core.exceptions import ValidationError as DjangoValidationError +from django.core.validators import validate_email from django.http import HttpResponse, FileResponse from django.urls import reverse from django.utils import timezone @@ -35,6 +41,7 @@ from .models import ( Crane, Vehicle, VehicleNotification, + EmailDispatchLog, ServiceContextNote, WorkOrder, WorkOrderPhoto, @@ -70,6 +77,7 @@ from .tasks import ( build_work_order_pdf_cached_task, cleanup_expired_generated_pdfs_task, process_work_order_invoice_ocr, + send_work_order_email_bundle_task, ) register_unicode_fonts() @@ -1458,6 +1466,207 @@ def _send_document_email(*, recipient, subject, body, filename, pdf_bytes): message.send(fail_silently=False) +def _parse_boolean_flag(value, *, field_name, default=False): + if value is None: + return default + if isinstance(value, bool): + return value + normalized = str(value).strip().lower() + if normalized in {'1', 'true', 'yes', 'da', 'on'}: + return True + if normalized in {'0', 'false', 'no', 'ne', 'off'}: + return False + raise DRFValidationError({field_name: f"Neispravna boolean vrijednost za {field_name}."}) + + +def _parse_recipients(payload, *, fallback_candidates): + raw_recipients = payload.get('recipients') + if raw_recipients is None: + single = payload.get('recipient') + raw_recipients = [single] if single is not None else [] + elif isinstance(raw_recipients, str): + raw_recipients = [raw_recipients] + elif not isinstance(raw_recipients, list): + raise DRFValidationError({'recipients': 'Polje recipients mora biti lista email adresa.'}) + + prepared = [] + for item in raw_recipients: + if not isinstance(item, str): + raise DRFValidationError({'recipients': 'Svaki primatelj mora biti email adresa.'}) + chunks = [chunk.strip() for chunk in item.split(',')] + prepared.extend(chunk for chunk in chunks if chunk) + + if not prepared: + prepared = [candidate for candidate in fallback_candidates if isinstance(candidate, str) and candidate.strip()] + + unique = OrderedDict() + for email in prepared: + normalized = email.strip() + if not normalized: + continue + try: + validate_email(normalized) + except DjangoValidationError: + raise DRFValidationError({'recipients': f'Neispravna email adresa: {normalized}.'}) + unique[normalized.lower()] = normalized + return list(unique.values()) + + +def _build_work_order_invoices_pdf_bytes(work_order): + try: + task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk)) + payload = task_result.get(timeout=45) + except KombuOperationalError: + payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get() + except CeleryTimeoutError: + raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."}) + + if not isinstance(payload, dict) or payload.get('error'): + raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."}) + pdf_b64 = payload.get('pdf_base64') + if not pdf_b64: + raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."}) + try: + return base64.b64decode(pdf_b64) + except (ValueError, TypeError): + raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."}) + + +def _guess_content_type(filename): + mime, _ = mimetypes.guess_type(str(filename or '')) + return mime or 'application/octet-stream' + + +def _file_attachment(file_field, fallback_name): + if not file_field: + return None + file_field.open('rb') + try: + content = file_field.read() + finally: + file_field.close() + if not content: + return None + filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name + return (filename, content, _guess_content_type(filename)) + + +def _build_image_attachments_for_work_order(work_order): + attachments = [] + photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at') + for index, photo in enumerate(photos, start=1): + attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin") + if attachment: + attachments.append(attachment) + + invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at') + for index, invoice in enumerate(invoices, start=1): + attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin") + if attachment: + attachments.append(attachment) + return attachments + + +def _parse_month_value(value): + if value in (None, ''): + today = timezone.localdate() + return today.year, today.month, today.strftime('%Y-%m') + normalized = str(value).strip() + parts = normalized.split('-') + if len(parts) != 2: + raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'}) + try: + year = int(parts[0]) + month = int(parts[1]) + except ValueError: + raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'}) + if year < 2000 or year > 2100 or month < 1 or month > 12: + raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'}) + return year, month, f"{year:04d}-{month:02d}" + + +def _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key): + from modules.task_management.models import Task + + tasks = ( + Task.objects + .filter( + is_active=True, + vehicle_id=work_order.vehicle_id, + scheduled_date__year=year, + scheduled_date__month=month, + ) + .select_related('assigned_to', 'work_order') + .order_by('scheduled_date', 'created_at') + ) + + csv_buffer = StringIO() + writer = csv.writer(csv_buffer) + writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis']) + for task in tasks: + assignee_name = _user_display_name(task.assigned_to) or '-' + work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-' + writer.writerow([ + task.scheduled_date.isoformat() if task.scheduled_date else '', + task.title or '', + task.status or '', + assignee_name, + work_order_label, + task.description or '', + ]) + csv_content = csv_buffer.getvalue().encode('utf-8') + filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv" + return (filename, csv_content, 'text/csv'), tasks.count() + + +def _resolve_service_note_for_email(*, user, note_id, work_order): + if note_id in (None, ''): + return None + note = ServiceContextNote.objects.select_related('created_by', 'recipient', 'work_order').filter( + pk=note_id, + is_active=True, + ).first() + if note is None: + raise DRFValidationError({'service_note_id': 'Odabrana bilješka ne postoji ili nije aktivna.'}) + if note.work_order_id and str(note.work_order_id) != str(work_order.pk): + raise DRFValidationError({'service_note_id': 'Bilješka nije povezana s ovim putnim nalogom.'}) + if not (user.is_staff or str(note.created_by_id) == str(user.id) or str(note.recipient_id) == str(user.id)): + raise DRFValidationError({'service_note_id': 'Nemate dozvolu za korištenje odabrane bilješke.'}) + return note + + +def _send_email_with_attachments(*, recipients, subject, body, attachments): + if not recipients: + raise DRFValidationError({'recipients': 'Nije pronađena nijedna email adresa za slanje.'}) + message = EmailMessage( + subject=subject, + body=body, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None), + to=recipients, + ) + for filename, content, content_type in attachments: + message.attach(filename, content, content_type) + message.send(fail_silently=False) + + +def _dispatch_work_order_email_background(task_kwargs): + try: + send_work_order_email_bundle_task.delay(**task_kwargs) + return 'celery' + except KombuOperationalError: + pass + + def _run_fallback(): + try: + send_work_order_email_bundle_task.run(**task_kwargs) + except Exception: + logger.exception("Greška u fallback background slanju emaila za putni nalog.") + + thread = threading.Thread(target=_run_fallback, name='work-order-email-fallback', daemon=True) + thread.start() + return 'thread' + + @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) def pusher_auth(request): @@ -1659,26 +1868,8 @@ class WorkOrderViewSet(viewsets.ModelViewSet): return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'invoices')) _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested') - try: - task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk)) - payload = task_result.get(timeout=45) - except KombuOperationalError: - payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get() - except CeleryTimeoutError: - raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."}) - - if not isinstance(payload, dict) or payload.get('error'): - raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."}) - pdf_b64 = payload.get('pdf_base64') - if not pdf_b64: - raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."}) - - try: - pdf_bytes = base64.b64decode(pdf_b64) - except (ValueError, TypeError): - raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."}) - - filename = payload.get('filename') or _pdf_filename(work_order, 'invoices') + pdf_bytes = _build_work_order_invoices_pdf_bytes(work_order) + filename = _pdf_filename(work_order, 'invoices') _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed') response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="{filename}"' @@ -1855,25 +2046,130 @@ class WorkOrderViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['post'], url_path='send-email') def send_email(self, request, pk=None): work_order = self.get_object() - recipient = _first_non_empty( - request.data.get('recipient'), - getattr(getattr(work_order.vehicle, 'client', None), 'email', None), - getattr(work_order.creator, 'email', None), + recipients = _parse_recipients( + request.data, + fallback_candidates=[ + getattr(getattr(work_order.vehicle, 'client', None), 'email', None), + getattr(work_order.creator, 'email', None), + ], ) - if not recipient: - raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."}) + if not recipients: + raise DRFValidationError({"recipients": "Nije pronađena email adresa za slanje."}) - pdf_bytes = _build_work_order_pdf(work_order) - subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}" - body = _first_non_empty(request.data.get('message')) or "U prilogu je PDF putnog naloga." - _send_document_email( - recipient=recipient, - subject=subject, - body=body, - filename=_pdf_filename(work_order, 'work_order'), - pdf_bytes=pdf_bytes, + include_work_order_pdf = _parse_boolean_flag( + request.data.get('include_work_order_pdf'), + field_name='include_work_order_pdf', + default=True, + ) + include_service_records_pdf = _parse_boolean_flag( + request.data.get('include_service_records_pdf'), + field_name='include_service_records_pdf', + default=False, + ) + include_invoices_pdf = _parse_boolean_flag( + request.data.get('include_invoices_pdf'), + field_name='include_invoices_pdf', + default=False, + ) + include_images = _parse_boolean_flag( + request.data.get('include_images'), + field_name='include_images', + default=False, + ) + include_monthly_tasks = _parse_boolean_flag( + request.data.get('include_monthly_tasks'), + field_name='include_monthly_tasks', + default=False, + ) + selected_note = _resolve_service_note_for_email( + user=request.user, + note_id=request.data.get('service_note_id') or request.data.get('note_id'), + work_order=work_order, + ) + if not any([ + include_work_order_pdf, + include_service_records_pdf, + include_invoices_pdf, + include_images, + include_monthly_tasks, + ]): + raise DRFValidationError({"detail": "Odaberite barem jedan PDF/prilog za slanje emaila."}) + month_year = None + month_number = None + month_key = '' + if include_monthly_tasks: + month_year, month_number, month_key = _parse_month_value(request.data.get('month')) + + subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}" + message = _first_non_empty(request.data.get('message')) or "U prilogu je dokumentacija putnog naloga." + selected_attachment_flags = [] + if include_work_order_pdf: + selected_attachment_flags.append({'type': 'work_order_pdf'}) + if include_service_records_pdf: + selected_attachment_flags.append({'type': 'service_records_pdf'}) + if include_invoices_pdf: + selected_attachment_flags.append({'type': 'invoices_pdf'}) + if include_images: + selected_attachment_flags.append({'type': 'images'}) + if include_monthly_tasks: + selected_attachment_flags.append({'type': 'monthly_tasks', 'month': month_key}) + + dispatch_log = EmailDispatchLog.objects.create( + dispatch_type='work_order', + requested_by=request.user, + work_order=work_order, + recipients=recipients, + subject=subject, + message=message, + attachments=selected_attachment_flags, + status='queued', + metadata={ + 'service_note_id': str(selected_note.pk) if selected_note else None, + }, + ) + + NotificationService.create_notification( + recipient=request.user, + title='Slanje emaila u obradi', + message=f'Pokrenuto je slanje emaila za putni nalog {_work_order_display_code(work_order)}.', + level='info', + send_email=False, + metadata={ + 'entity_type': 'work_order_email', + 'stage': 'requested', + 'work_order_id': str(work_order.pk), + 'recipients_count': len(recipients), + }, + ) + dispatch_backend = _dispatch_work_order_email_background( + { + 'work_order_id': str(work_order.pk), + 'requested_by_id': str(request.user.id), + 'recipients': recipients, + 'subject': subject, + 'message': message, + 'include_work_order_pdf': include_work_order_pdf, + 'include_service_records_pdf': include_service_records_pdf, + 'include_invoices_pdf': include_invoices_pdf, + 'include_images': include_images, + 'include_monthly_tasks': include_monthly_tasks, + 'month_year': month_year, + 'month_number': month_number, + 'month_key': month_key, + 'service_note_id': str(selected_note.pk) if selected_note else None, + 'dispatch_log_id': str(dispatch_log.pk), + } + ) + + return Response( + { + "queued": True, + "recipients": recipients, + "work_order_id": str(work_order.pk), + "dispatch_backend": dispatch_backend, + }, + status=status.HTTP_202_ACCEPTED, ) - return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK) class VehicleServiceRecordViewSet(viewsets.ModelViewSet): @@ -1902,7 +2198,6 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet): ) if not recipient: raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."}) - pdf_bytes = _build_service_record_pdf(service_record) subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}" body = _first_non_empty(request.data.get('message')) or ( @@ -1915,6 +2210,17 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet): filename=f"{service_record.pk}.service-record.pdf", pdf_bytes=pdf_bytes, ) + EmailDispatchLog.objects.create( + dispatch_type='service_record', + requested_by=request.user, + service_record=service_record, + recipients=[recipient], + subject=subject, + message=body, + attachments=[{'type': 'service_record_pdf', 'filename': f"{service_record.pk}.service-record.pdf"}], + status='sent', + sent_at=timezone.now(), + ) return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK) diff --git a/frontend/src/components/dashboard/WorkOrderDetailModal.jsx b/frontend/src/components/dashboard/WorkOrderDetailModal.jsx index 1e776f3..a321bfa 100644 --- a/frontend/src/components/dashboard/WorkOrderDetailModal.jsx +++ b/frontend/src/components/dashboard/WorkOrderDetailModal.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; +import { useStore } from '@nanostores/preact'; import WorkOrderImageCarousel from './WorkOrderImageCarousel'; import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload'; import ModalShell from '../ui/ModalShell'; @@ -12,6 +13,7 @@ import { } from '../../stores/fleetDashboardStore'; import { showToast } from '../../stores/toastStore'; import { formatWorkOrderDisplayCode } from '../../lib/displayIds'; +import { $serviceNotes, $serviceNotesLoading, fetchServiceNotes } from '../../stores/serviceNotesStore'; const PURPOSE_OPTIONS = [ { value: 'defektaza', label: 'Defektaža' }, @@ -20,6 +22,8 @@ const PURPOSE_OPTIONS = [ ]; export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) { + const serviceNotes = useStore($serviceNotes); + const serviceNotesLoading = useStore($serviceNotesLoading); const [isEditing, setIsEditing] = useState(false); const [form, setForm] = useState({ has_travel_order: false, @@ -52,6 +56,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi const [emailPayload, setEmailPayload] = useState({ subject: '', message: '', + includeWorkOrderPdf: true, + includeServiceRecordsPdf: false, + includeInvoicesPdf: false, + includeImages: false, + includeMonthlyTasks: false, + month: new Date().toISOString().slice(0, 7), + serviceNoteId: '', }); const [invoices, setInvoices] = useState([]); const [invoiceModalOpen, setInvoiceModalOpen] = useState(false); @@ -83,6 +94,16 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi } return PURPOSE_OPTIONS; }, [form.purpose]); + const selectableServiceNotes = useMemo(() => { + if (!workOrder?.id) return []; + return serviceNotes.filter((note) => ( + !note?.is_closed + && ( + !note?.work_order + || String(note.work_order) === String(workOrder.id) + ) + )); + }, [serviceNotes, workOrder?.id]); useEffect(() => { if (!open || typeof window === 'undefined') return; @@ -122,6 +143,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi setEmailPayload({ subject: '', message: '', + includeWorkOrderPdf: true, + includeServiceRecordsPdf: false, + includeInvoicesPdf: false, + includeImages: false, + includeMonthlyTasks: false, + month: new Date().toISOString().slice(0, 7), + serviceNoteId: '', }); setInvoices([]); setInvoiceModalOpen(false); @@ -148,7 +176,10 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi setLoadingTeamMembers(true); (async () => { try { - const members = await fetchTeamMembers(); + const [members] = await Promise.all([ + fetchTeamMembers(), + fetchServiceNotes(), + ]); if (!cancelled) { setTeamMembers(members); } @@ -403,29 +434,34 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.'); return; } - - let failed = 0; - for (const recipient of recipients) { - try { - await sendWorkOrderEmail( - workOrder.id, - { - recipient, - subject: emailPayload.subject.trim() || undefined, - message: emailPayload.message.trim() || undefined, - }, - { toast: false } - ); - showToast(`Email je poslan: ${recipient}`, 'success'); - } catch { - failed += 1; - showToast(`Neuspješno slanje: ${recipient}`, 'error'); - } - } - if (failed > 0) { - setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`); + if ( + !emailPayload.includeWorkOrderPdf + && !emailPayload.includeServiceRecordsPdf + && !emailPayload.includeInvoicesPdf + && !emailPayload.includeImages + && !emailPayload.includeMonthlyTasks + ) { + setWidgetError('Odaberite barem jedan PDF/prilog za slanje.'); return; } + + await sendWorkOrderEmail( + workOrder.id, + { + recipients, + subject: emailPayload.subject.trim() || undefined, + message: emailPayload.message.trim() || undefined, + include_work_order_pdf: emailPayload.includeWorkOrderPdf, + include_service_records_pdf: emailPayload.includeServiceRecordsPdf, + include_invoices_pdf: emailPayload.includeInvoicesPdf, + include_images: emailPayload.includeImages, + include_monthly_tasks: emailPayload.includeMonthlyTasks, + month: emailPayload.includeMonthlyTasks ? emailPayload.month : undefined, + service_note_id: emailPayload.serviceNoteId || undefined, + }, + { toast: false } + ); + showToast(`Slanje je pokrenuto u pozadini za ${recipients.length} primatelja. Rezultat je u Notifikacijama.`, 'info'); setEmailModalOpen(false); } catch (err) { setWidgetError(err?.message || 'Neuspješno slanje emaila za putni nalog.'); @@ -917,6 +953,74 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi placeholder="Poruka (opcionalno)" className="min-h-24 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main" /> +
+ + + + + + + +
)} + {failedEmailCachedPdfs.length > 0 && ( +
+

+ Cached PDF datoteke +

+ {failedEmailCachedPdfs.map((item, index) => ( + + ))} +
+ )}
diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index 2abe632..0f1ed10 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -887,9 +887,19 @@ export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {} if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } - const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, payload); + const recipients = Array.isArray(payload?.recipients) + ? payload.recipients.filter(Boolean) + : []; + const normalizedPayload = { + ...payload, + recipients: recipients.length > 0 ? recipients : undefined, + }; + if (!normalizedPayload.recipients || normalizedPayload.recipients.length === 0) { + delete normalizedPayload.recipients; + } + const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, normalizedPayload); if (options.toast !== false) { - showToast('Email za putni nalog je poslan.', 'success'); + showToast('Zahtjev za slanje emaila je pokrenut u pozadini.', 'info'); } return response; } @@ -907,6 +917,34 @@ export async function sendServiceRecordEmail(serviceRecordId, payload = {}, opti if (!serviceRecordId) { throw new Error('Service record ID je obavezan.'); } + const recipients = Array.isArray(payload?.recipients) + ? payload.recipients.filter(Boolean) + : []; + if (recipients.length > 0) { + const failures = []; + for (const recipient of recipients) { + try { + await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, { + ...payload, + recipient, + }); + } catch (error) { + failures.push({ recipient, error }); + } + } + if (failures.length > 0) { + const firstError = failures[0]?.error; + const message = firstError?.message || `Neuspješno slanje za ${failures.length} primatelja.`; + const batchError = new Error(message); + batchError.failures = failures; + throw batchError; + } + if (options.toast !== false) { + showToast('Email za servisni zapis je poslan.', 'success'); + } + return { sent: true, recipients }; + } + const response = await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, payload); if (options.toast !== false) { showToast('Email za servisni zapis je poslan.', 'success');