feat: dodaj dodatne troškove s OCR sinkronizacijom
Dodana je tablica DODATNI TROŠKOVI za putni nalog (backend model + API + frontend modal) s izračunom ukupnog iznosa za isplatu. Upload računa sada odmah upisuje redak u dodatne troškove, a asinkroni OCR (Celery) naknadno pokušava popuniti broj računa i iznos iz slike/PDF-a. Uvedena je invalidacija PDF cache-a nakon promjena računa/dodatnih troškova kako bi generirani PDF uvijek prikazivao najnovije podatke. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
|
||||
const EMPTY_ROW = {
|
||||
naziv: '',
|
||||
broj_racuna: '',
|
||||
ukupan_iznos: '',
|
||||
prilog: '',
|
||||
source_invoice_id: '',
|
||||
};
|
||||
|
||||
function parseAmount(value) {
|
||||
const normalized = String(value || '').trim().replace('€', '').replace(/\s+/g, '').replace(',', '.');
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export default function WorkOrderAdditionalCostsModal({
|
||||
open,
|
||||
workOrder,
|
||||
tableData,
|
||||
saving = false,
|
||||
onClose,
|
||||
onSave,
|
||||
}) {
|
||||
const [rows, setRows] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
const incoming = Array.isArray(tableData?.data?.rows) ? tableData.data.rows : [];
|
||||
setRows(incoming.length > 0 ? incoming.map((row) => ({ ...EMPTY_ROW, ...(row || {}) })) : [{ ...EMPTY_ROW }]);
|
||||
}, [open, tableData]);
|
||||
|
||||
const total = useMemo(() => {
|
||||
const value = rows.reduce((sum, row) => sum + parseAmount(row.ukupan_iznos), 0);
|
||||
return value.toFixed(2);
|
||||
}, [rows]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const updateRowField = (index, field, value) => {
|
||||
setRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: value } : row)));
|
||||
};
|
||||
|
||||
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
|
||||
const removeRow = (index) => setRows((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const handleSave = async () => {
|
||||
const payloadRows = rows.map((row) => ({
|
||||
naziv: String(row.naziv || '').trim(),
|
||||
broj_racuna: String(row.broj_racuna || '').trim(),
|
||||
ukupan_iznos: String(row.ukupan_iznos || '').trim(),
|
||||
prilog: String(row.prilog || '').trim(),
|
||||
source_invoice_id: String(row.source_invoice_id || '').trim(),
|
||||
}));
|
||||
await onSave?.({ rows: payloadRows });
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
overlayClassName="z-[70] overflow-y-auto p-4 pt-16"
|
||||
contentClassName="flex min-h-full items-start justify-center"
|
||||
panelClassName="w-full max-w-5xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-main">DODATNI TROŠKOVI</h3>
|
||||
<p className="text-xs text-text-muted">{workOrder?.display_code || workOrder?.id || '-'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Zatvori"
|
||||
className="ml-4 rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto px-5 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
+ Dodaj redak
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700">
|
||||
<table className="min-w-[900px] w-full bg-white text-xs dark:bg-gray-900">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-700 dark:bg-gray-800">
|
||||
<th className="w-8 px-2 py-2.5 text-center text-gray-400">#</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">NAZIV</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Broj računa</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Ukupan iznos</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Prilog</th>
|
||||
<th className="px-2 py-2.5 text-gray-400">Akcija</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={index} className="border-t border-gray-100 dark:border-gray-700">
|
||||
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400">{index + 1}</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.naziv}
|
||||
onInput={(event) => updateRowField(index, 'naziv', event.currentTarget.value)}
|
||||
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.broj_racuna}
|
||||
onInput={(event) => updateRowField(index, 'broj_racuna', event.currentTarget.value)}
|
||||
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.ukupan_iznos}
|
||||
onInput={(event) => updateRowField(index, 'ukupan_iznos', event.currentTarget.value)}
|
||||
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.prilog}
|
||||
onInput={(event) => updateRowField(index, 'prilog', event.currentTarget.value)}
|
||||
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(index)}
|
||||
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Obriši
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-100 text-xs font-bold dark:border-gray-600 dark:bg-gray-800">
|
||||
<td colSpan={3} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO ZA ISPLATU</td>
|
||||
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100">{total} €</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-border-hairline px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Odustani
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Spremam…' : 'Spremi dodatne troškove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,19 @@ 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') {
|
||||
@@ -71,6 +74,9 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
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();
|
||||
@@ -105,10 +111,11 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [contextPayload, order, invoiceItems] = await Promise.all([
|
||||
const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([
|
||||
fetchWorkOrderTaskServiceContext(workOrderId),
|
||||
fetchWorkOrderById(workOrderId),
|
||||
fetchWorkOrderInvoices(workOrderId),
|
||||
fetchWorkOrderAdditionalCostsTable(workOrderId),
|
||||
]);
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -116,6 +123,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
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.');
|
||||
@@ -187,6 +195,18 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -289,15 +309,30 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<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>
|
||||
<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 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">
|
||||
@@ -337,6 +372,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
onClose={() => setEditingTask(null)}
|
||||
onSave={handleSaveWorkHoursTable}
|
||||
/>
|
||||
<WorkOrderAdditionalCostsModal
|
||||
open={editingAdditionalCosts}
|
||||
workOrder={workOrder}
|
||||
tableData={additionalCostsTable}
|
||||
saving={savingAdditionalCosts}
|
||||
onClose={() => setEditingAdditionalCosts(false)}
|
||||
onSave={handleSaveAdditionalCostsTable}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -712,10 +712,32 @@ export async function fetchWorkOrderById(workOrderId) {
|
||||
|
||||
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return { tasks: [] };
|
||||
return { tasks: [], additional_costs_table: null };
|
||||
}
|
||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
|
||||
return payload && typeof payload === 'object' ? payload : { tasks: [] };
|
||||
return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null };
|
||||
}
|
||||
|
||||
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return { data: { rows: [] }, total_for_payout: '0.00' };
|
||||
}
|
||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`);
|
||||
return payload && typeof payload === 'object'
|
||||
? payload
|
||||
: { data: { rows: [] }, total_for_payout: '0.00' };
|
||||
}
|
||||
|
||||
export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
const payload = await api.put(
|
||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`,
|
||||
data ?? { rows: [] },
|
||||
);
|
||||
showToast('Dodatni troškovi su uspješno spremljeni.', 'success');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function updateTaskWorkHoursTable(taskId, data) {
|
||||
|
||||
Reference in New Issue
Block a user