Files
004.erp.generic/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
mariomitte 61996bd1a0
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
fix: premjesti gumb tablice sati u zaglavlje servisnih zapisa
U /putni-nalozi/racuni gumb 'Uredi tablicu radnih sati' premješten je
iz kartica pojedinih taskova u zaglavlje grupe Servisni zapisi, pokraj
gumba za preuzimanje PDF-a, po istom UX obrascu kao dodatni troškovi.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 21:41:00 +02:00

391 lines
19 KiB
JavaScript

import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import {
downloadWorkOrderPdf,
downloadWorkOrderInvoicesPdf,
downloadWorkOrderServiceRecordsPdf,
fetchWorkOrderAdditionalCostsTable,
fetchWorkOrderById,
fetchWorkOrderInvoices,
fetchWorkOrderTaskServiceContext,
updateTaskWorkHoursTable,
updateWorkOrderAdditionalCostsTable,
} from '../../stores/fleetDashboardStore';
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') {
return '';
}
const params = new URLSearchParams(window.location.search);
return params.get('workOrderId') || '';
}
function InvoiceImagePreview({ imageUrl, alt, contentType }) {
const [broken, setBroken] = useState(false);
const isPdf = String(contentType || '').toLowerCase() === 'application/pdf';
if (isPdf && imageUrl) {
return (
<a
href={imageUrl}
target="_blank"
rel="noopener noreferrer"
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-main hover:bg-canvas-base"
>
Otvori PDF
</a>
);
}
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">
Bez slike
</div>
);
}
return (
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="inline-flex">
<img
src={imageUrl}
alt={alt}
onError={() => setBroken(true)}
className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
/>
</a>
);
}
export default function WorkOrderInvoicesPdfPage() {
const authReady = useStore($authReady);
const token = useStore($accessToken);
const [workOrderId, setWorkOrderId] = useState('');
const [workOrder, setWorkOrder] = useState(null);
const [invoices, setInvoices] = useState([]);
const [taskContext, setTaskContext] = useState({ tasks: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingTask, setEditingTask] = useState(null);
const [savingWorkHours, setSavingWorkHours] = useState(false);
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
useEffect(() => {
hydrateAuthFromStorage();
const id = readWorkOrderIdFromQuery();
setWorkOrderId(id);
}, []);
useEffect(() => {
if (authReady) {
fetchNotifications();
connectNotifications();
}
}, [authReady]);
useEffect(() => {
if (!workOrderId) {
setLoading(false);
setError('Nedostaje workOrderId u URL-u.');
return;
}
if (!authReady) {
return;
}
if (!token) {
setLoading(false);
setError('Sesija nije aktivna. Prijavite se ponovno.');
return;
}
let cancelled = false;
setLoading(true);
setError('');
(async () => {
try {
const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([
fetchWorkOrderTaskServiceContext(workOrderId),
fetchWorkOrderById(workOrderId),
fetchWorkOrderInvoices(workOrderId),
fetchWorkOrderAdditionalCostsTable(workOrderId),
]);
if (cancelled) {
return;
}
setTaskContext(contextPayload);
setWorkOrder(order);
setInvoices(invoiceItems);
setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' });
} catch (err) {
if (!cancelled) {
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [workOrderId, authReady, token]);
const title = useMemo(() => {
if (!workOrder) {
return 'Računi putnog naloga';
}
return formatWorkOrderDisplayCode(workOrder);
}, [workOrder]);
const mediaPaths = useMemo(() => {
const paths = [];
if (Array.isArray(taskContext.tasks)) {
taskContext.tasks.forEach((task) => {
if (!Array.isArray(task?.service_records)) return;
task.service_records.forEach((record) => {
if (!Array.isArray(record?.photos)) return;
record.photos.forEach((photo) => {
const candidate = photo?.optimized_url || photo?.image_url;
if (candidate) {
paths.push(candidate);
}
});
});
});
}
if (Array.isArray(invoices)) {
invoices.forEach((invoice) => {
const invoiceImage = invoice?.image_url || invoice?.image;
if (invoiceImage) {
paths.push(invoiceImage);
}
});
}
return paths;
}, [taskContext.tasks, invoices]);
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(mediaPaths);
const handleSaveWorkHoursTable = async (tableData) => {
if (!editingTask?.id) return;
setSavingWorkHours(true);
try {
const response = await updateTaskWorkHoursTable(editingTask.id, tableData);
setTaskContext((prev) => ({
...prev,
tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => (
String(task.id) === String(editingTask.id)
? { ...task, work_hours_table: response?.data ?? null }
: task
)),
}));
setEditingTask(null);
} finally {
setSavingWorkHours(false);
}
};
const handleSaveAdditionalCostsTable = async (tableData) => {
if (!workOrderId) return;
setSavingAdditionalCosts(true);
try {
const response = await updateWorkOrderAdditionalCostsTable(workOrderId, tableData);
setAdditionalCostsTable(response || { data: { rows: [] }, total_for_payout: '0.00' });
setEditingAdditionalCosts(false);
} finally {
setSavingAdditionalCosts(false);
}
};
return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-text-main">{title}</h1>
<p className="text-sm text-text-muted">
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
</p>
</div>
<button
type="button"
onClick={() => downloadWorkOrderPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF putnog naloga
</button>
</div>
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
const firstTask = Array.isArray(taskContext.tasks) ? taskContext.tasks[0] : null;
if (!firstTask) return;
setEditingTask(firstTask);
}}
disabled={!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Uredi tablicu radnih sati
</button>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF servisnih zapisa
</button>
</div>
</div>
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
{!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
<p className="text-sm text-text-muted">Nema taskova povezanih s ovim putnim nalogom.</p>
)}
{!loading && !error && Array.isArray(taskContext.tasks) && taskContext.tasks.length > 0 && (
<div className="space-y-4">
{taskContext.tasks.map((task) => (
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
</div>
<div className="mt-3 space-y-3">
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
) : (
task.service_records.map((record) => (
<div key={record.id} className="rounded border border-border-hairline px-3 py-2">
<div className="space-y-1 text-xs text-text-main">
<div><span className="font-semibold">Servis:</span> {record.description || '-'}</div>
<div><span className="font-semibold">Datum:</span> {record.service_date || '-'}</div>
<div><span className="font-semibold">Trošak:</span> {record.cost || '-'} EUR</div>
<div><span className="font-semibold">KM:</span> {record.mileage ?? '-'}</div>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{Array.isArray(record.photos) && record.photos.length > 0 ? (
record.photos.map((photo) => (
<a
key={photo.id}
href={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
>
<img
src={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
alt={`Servisna slika ${photo.id}`}
className="h-20 w-20 rounded border border-border-hairline object-cover"
/>
</a>
))
) : (
<span className="text-xs text-text-muted">Bez slika</span>
)}
</div>
</div>
))
)}
</div>
</article>
))}
</div>
)}
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setEditingAdditionalCosts(true)}
disabled={!workOrderId}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Uredi dodatne troškove
</button>
<button
type="button"
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi račune putnog naloga
</button>
</div>
</div>
{!loading && !error && (
<p className="mb-3 text-sm text-text-muted">
Ukupno dodatni troškovi: <span className="font-semibold text-text-main">{additionalCostsTable?.total_for_payout || '0.00'} </span>
</p>
)}
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
{!loading && error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{!loading && !error && invoices.length === 0 && (
<p className="text-sm text-text-muted">Za ovaj putni nalog nema unesenih računa.</p>
)}
{!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>
</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}
task={editingTask}
defaultLocation={workOrder?.location || ''}
saving={savingWorkHours}
onClose={() => setEditingTask(null)}
onSave={handleSaveWorkHoursTable}
/>
<WorkOrderAdditionalCostsModal
open={editingAdditionalCosts}
workOrder={workOrder}
tableData={additionalCostsTable}
saving={savingAdditionalCosts}
onClose={() => setEditingAdditionalCosts(false)}
onSave={handleSaveAdditionalCostsTable}
/>
</section>
);
}