fix: ispravi prikaz računa u admin/frontendu i smanji PDF slike
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

- admin.py: thumbnail_preview u WorkOrderInvoiceInline i WorkOrderInvoiceAdmin
  prikazuje slike/PDF veze putem optimiziranog image endpointa
- serializers.py: image_url sada uključuje ?w=1280&q=80&fmt=jpeg za ne-PDF slike
- views.py: doda _compress_image_for_pdf() helper, zamijeni ImageReader(photo.image)
  s kompresiranom JPEG verzijom na oba mjesta u PDF generiranju
- tasks.py: kompresija invoice slike u JPEG u memoriji prije ImageReader poziva
- WorkOrderInvoicesPdfPage.jsx:
  * InvoiceImagePreview prikazuje 'Učitavanje...' dok se blob dohvaća (ne 401 fallback)
  * uklonjen resolveMediaUrl fallback koji uzrokuje 401 greške
  * svaka invoice kartica je klikabilna i otvara InvoiceDetailModal
  * InvoiceDetailModal prikazuje detalje i uvećanu sliku/PDF vezu

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-31 11:54:34 +02:00
parent ae6ae08fb1
commit 525152b817
5 changed files with 207 additions and 30 deletions

View File

@@ -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 (
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
<span className="animate-pulse">Učitavanje</span>
</div>
);
}
if (!imageUrl || broken) {
return (
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
@@ -58,14 +68,76 @@ function InvoiceImagePreview({ imageUrl, alt, contentType }) {
}
return (
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="inline-flex">
<button
type="button"
onClick={onClick}
className="inline-flex cursor-pointer focus:outline-none"
aria-label={`Otvori pregled: ${alt}`}
>
<img
src={imageUrl}
alt={alt}
onError={() => setBroken(true)}
className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
className="h-28 w-28 rounded-lg border border-border-hairline object-cover hover:opacity-90 transition-opacity"
/>
</a>
</button>
);
}
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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="relative flex max-h-[95vh] w-full max-w-3xl flex-col overflow-auto rounded-xl bg-canvas-base shadow-2xl">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
<h2 className="text-base font-semibold text-text-main">{invoice.naziv_racuna || 'Račun'}</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-text-muted hover:text-text-main focus:outline-none"
aria-label="Zatvori"
>
</button>
</div>
<div className="space-y-1 px-5 py-3 text-sm text-text-main border-b border-border-hairline">
<div><span className="font-semibold">Naziv:</span> {invoice.naziv_racuna || '-'}</div>
<div><span className="font-semibold">Lokacija:</span> {invoice.lokacija || '-'}</div>
<div><span className="font-semibold">Datum:</span> {invoice.datum || '-'}</div>
<div><span className="font-semibold">Opis:</span> {invoice.opis || '-'}</div>
</div>
<div className="flex flex-1 items-center justify-center p-5">
{isPdf && imageUrl ? (
<a
href={imageUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
>
Otvori PDF u novom tabu
</a>
) : imageUrl ? (
<img
src={imageUrl}
alt={invoice.naziv_racuna}
className="max-h-[60vh] max-w-full rounded-lg object-contain shadow"
/>
) : (
<p className="text-sm text-text-muted">Slika nije dostupna.</p>
)}
</div>
</div>
</div>
);
}
@@ -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 && (
<div className="space-y-3">
{invoices.map((invoice) => (
<article key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
{invoices.map((invoice) => {
const rawUrl = invoice.image_url || invoice.image || '';
const blobUrl = getAuthenticatedMediaSrc(rawUrl);
return (
<article
key={invoice.id}
className="cursor-pointer rounded-lg border border-border-hairline bg-canvas-base p-3 hover:bg-canvas-deep transition-colors"
onClick={() => setInvoiceDetailModal(invoice)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setInvoiceDetailModal(invoice); }}
>
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
</div>
<InvoiceImagePreview
imageUrl={blobUrl}
rawUrl={rawUrl}
alt={`Račun ${invoice.naziv_racuna}`}
contentType={invoice.image_content_type}
onClick={(e) => { e.stopPropagation(); setInvoiceDetailModal(invoice); }}
/>
</div>
</article>
);
})}
</div>
<InvoiceImagePreview
imageUrl={getAuthenticatedMediaSrc(invoice.image_url || invoice.image) || resolveMediaUrl(invoice.image_url || invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`}
contentType={invoice.image_content_type}
/>
</div>
</article>
))}
</div>
)}
)}
</div>
<TaskWorkHoursTableModal
open={!!editingTask}
@@ -455,6 +541,14 @@ export default function WorkOrderInvoicesPdfPage() {
}}
onSave={handleSaveServiceReportNote}
/>
{invoiceDetailModal && (
<InvoiceDetailModal
invoice={invoiceDetailModal}
imageUrl={getAuthenticatedMediaSrc(invoiceDetailModal.image_url || invoiceDetailModal.image || '')}
contentType={invoiceDetailModal.image_content_type}
onClose={() => setInvoiceDetailModal(null)}
/>
)}
</section>
);
}