diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py index 254ffe1..849d950 100644 --- a/backend/modules/fleet/admin.py +++ b/backend/modules/fleet/admin.py @@ -1,3 +1,4 @@ +from pathlib import Path from django.contrib import admin from django.contrib.admin import ModelAdmin from django.utils.html import format_html @@ -61,8 +62,27 @@ class ServiceRecordInline(admin.TabularInline): class WorkOrderInvoiceInline(admin.TabularInline): model = WorkOrderInvoice extra = 0 - fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active') - readonly_fields = ('created_at',) + fields = ('thumbnail_preview', 'naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active') + readonly_fields = ('thumbnail_preview', 'created_at') + + @admin.display(description="Pregled") + def thumbnail_preview(self, obj): + if not obj.pk or not obj.image: + return "—" + suffix = Path(obj.image.name or '').suffix.lower() + if suffix == '.pdf': + return format_html( + '📄 PDF', + obj.pk, + ) + return format_html( + '' + '' + '', + obj.pk, + obj.pk, + ) class WorkOrderPhotoInline(admin.TabularInline): @@ -193,10 +213,30 @@ class WorkOrderAdmin(admin.ModelAdmin): @admin.register(WorkOrderInvoice) class WorkOrderInvoiceAdmin(admin.ModelAdmin): - list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active') + list_display = ('id', 'image_preview', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active') list_filter = ('datum', 'is_active', 'work_order') search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id') ordering = ('-datum', '-created_at') + readonly_fields = ('image_preview', 'created_at') + + @admin.display(description="Slika") + def image_preview(self, obj): + if not obj.pk or not obj.image: + return "—" + suffix = Path(obj.image.name or '').suffix.lower() + if suffix == '.pdf': + return format_html( + '📄 PDF', + obj.pk, + ) + return format_html( + '' + '' + '', + obj.pk, + obj.pk, + ) @admin.register(WorkOrderAdditionalCostsTable) diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py index 96a7aac..74cb1cc 100644 --- a/backend/modules/fleet/serializers.py +++ b/backend/modules/fleet/serializers.py @@ -373,9 +373,13 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer): return None if request is None: return None - return request.build_absolute_uri( + base = request.build_absolute_uri( reverse('work-order-invoice-image', kwargs={'pk': obj.pk}) ) + suffix = Path(obj.image.name or '').suffix.lower() + if suffix == '.pdf': + return base + return f"{base}?w=1280&q=80&fmt=jpeg" def get_image_content_type(self, obj): if not obj.image: diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index 88f0cba..32ec4a1 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -645,10 +645,19 @@ def _build_work_order_invoices_pdf(work_order): y = content_top max_height = y - content_bottom + # Kompresiraj u JPEG u memoriji radi manje veličine PDF-a + compress_w = min(image.width, 1280) + if image.width > compress_w: + ratio_c = compress_w / float(image.width) + image = image.resize((compress_w, max(1, int(image.height * ratio_c))), Image.LANCZOS) + buf = BytesIO() + image.save(buf, format='JPEG', quality=75, optimize=True) + buf.seek(0) + ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0) draw_width = max(1, int(image.width * ratio)) draw_height = max(1, int(image.height * ratio)) - image_reader = ImageReader(image) + image_reader = ImageReader(buf) pdf.drawImage( image_reader, margin, diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 4321d6a..0501caf 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -208,6 +208,32 @@ def _docx_filename(work_order, doc_type): return f"{display_code}.work-order.docx" +def _compress_image_for_pdf(image_field, max_width=1280, quality=75): + """ + Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji, + vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna. + """ + try: + image_field.open('rb') + with Image.open(image_field) as src: + img = src.convert('RGB') + if img.width > max_width: + ratio = max_width / float(img.width) + new_h = max(1, int(img.height * ratio)) + img = img.resize((max_width, new_h), Image.LANCZOS) + buf = BytesIO() + img.save(buf, format='JPEG', quality=quality, optimize=True) + buf.seek(0) + return ImageReader(buf) + except Exception: + return None + finally: + try: + image_field.close() + except Exception: + pass + + def _get_cached_pdf(work_order, pdf_type): now = timezone.now() expected_filename = _pdf_filename(work_order, pdf_type) @@ -1166,7 +1192,9 @@ def _build_work_order_service_records_pdf(work_order): prepared = [] for photo in row_photos: try: - image_reader = ImageReader(photo.image) + image_reader = _compress_image_for_pdf(photo.image) + if image_reader is None: + continue source_w, source_h = image_reader.getSize() if not source_w or not source_h: continue @@ -1390,7 +1418,9 @@ def _build_service_record_pdf(service_record): prepared = [] for photo in row_photos: try: - image_reader = ImageReader(photo.image) + image_reader = _compress_image_for_pdf(photo.image) + if image_reader is None: + continue source_w, source_h = image_reader.getSize() if not source_w or not source_h: continue diff --git a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx index fda1ab2..9bec321 100644 --- a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx +++ b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx @@ -19,7 +19,7 @@ import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/a import { fetchNotifications, connectNotifications } from '../../stores/notificationStore'; import { formatWorkOrderDisplayCode } from '../../lib/displayIds'; import { showToast } from '../../stores/toastStore'; -import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel'; +import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel'; import TaskWorkHoursTableModal from './TaskWorkHoursTableModal'; import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal'; import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal'; @@ -32,9 +32,11 @@ function readWorkOrderIdFromQuery() { return params.get('workOrderId') || ''; } -function InvoiceImagePreview({ imageUrl, alt, contentType }) { +function InvoiceImagePreview({ imageUrl, rawUrl, alt, contentType, onClick }) { const [broken, setBroken] = useState(false); const isPdf = String(contentType || '').toLowerCase() === 'application/pdf'; + const isProtected = typeof rawUrl === 'string' && /\/api\/fleet\//i.test(rawUrl); + const isLoading = isProtected && !imageUrl && !broken; if (isPdf && imageUrl) { return ( @@ -49,6 +51,14 @@ function InvoiceImagePreview({ imageUrl, alt, contentType }) { ); } + if (isLoading) { + return ( +
+ Učitavanje… +
+ ); + } + if (!imageUrl || broken) { return (
@@ -58,14 +68,76 @@ function InvoiceImagePreview({ imageUrl, alt, contentType }) { } return ( - + + ); +} + +function InvoiceDetailModal({ invoice, imageUrl, contentType, onClose }) { + const isPdf = String(contentType || '').toLowerCase() === 'application/pdf'; + + useEffect(() => { + const handleKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [onClose]); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+

{invoice.naziv_racuna || 'Račun'}

+ +
+
+
Naziv: {invoice.naziv_racuna || '-'}
+
Lokacija: {invoice.lokacija || '-'}
+
Datum: {invoice.datum || '-'}
+
Opis: {invoice.opis || '-'}
+
+
+ {isPdf && imageUrl ? ( + + Otvori PDF u novom tabu + + ) : imageUrl ? ( + {invoice.naziv_racuna} + ) : ( +

Slika nije dostupna.

+ )} +
+
+
); } @@ -86,6 +158,7 @@ export default function WorkOrderInvoicesPdfPage() { const [editingServiceReportNote, setEditingServiceReportNote] = useState(false); const [savingServiceReportNote, setSavingServiceReportNote] = useState(false); const [serviceReportNoteError, setServiceReportNoteError] = useState(''); + const [invoiceDetailModal, setInvoiceDetailModal] = useState(null); useEffect(() => { hydrateAuthFromStorage(); @@ -408,25 +481,38 @@ export default function WorkOrderInvoicesPdfPage() { )} {!loading && !error && invoices.length > 0 && (
- {invoices.map((invoice) => ( -
-
-
-
naziv_računa: {invoice.naziv_racuna}
-
lokacija: {invoice.lokacija || '-'}
-
datum: {invoice.datum || '-'}
-
opis: {invoice.opis || '-'}
+ {invoices.map((invoice) => { + const rawUrl = invoice.image_url || invoice.image || ''; + const blobUrl = getAuthenticatedMediaSrc(rawUrl); + return ( +
setInvoiceDetailModal(invoice)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setInvoiceDetailModal(invoice); }} + > +
+
+
naziv_računa: {invoice.naziv_racuna}
+
lokacija: {invoice.lokacija || '-'}
+
datum: {invoice.datum || '-'}
+
opis: {invoice.opis || '-'}
+
+ { e.stopPropagation(); setInvoiceDetailModal(invoice); }} + /> +
+
+ ); + })}
- -
-
- ))} -
- )} + )}
+ {invoiceDetailModal && ( + setInvoiceDetailModal(null)} + /> + )} ); }