fix: add editable work order travel expenses
Introduce a dedicated travel-expenses table for work orders so Broj sati, Količina dnevnica, and Iznos dnevnice can be reviewed and edited before PDF generation. The PDF now uses the stored travel-expenses values, with default HR rate and cache invalidation on updates.
This commit is contained in:
@@ -10,10 +10,12 @@ import {
|
||||
fetchWorkOrderAdditionalCostsTable,
|
||||
fetchWorkOrderById,
|
||||
fetchWorkOrderInvoices,
|
||||
fetchWorkOrderTravelExpensesTable,
|
||||
fetchWorkOrderTaskServiceContext,
|
||||
updateTaskWorkHoursTable,
|
||||
updateTaskServiceReportNote,
|
||||
updateWorkOrderAdditionalCostsTable,
|
||||
updateWorkOrderTravelExpensesTable,
|
||||
} from '../../stores/fleetDashboardStore';
|
||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||
@@ -21,6 +23,7 @@ import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
||||
import WorkOrderTravelExpensesModal from './WorkOrderTravelExpensesModal';
|
||||
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
|
||||
|
||||
function readWorkOrderIdFromQuery() {
|
||||
@@ -147,6 +150,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
const [workOrder, setWorkOrder] = useState(null);
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [taskContext, setTaskContext] = useState({ tasks: [] });
|
||||
const [travelExpensesTable, setTravelExpensesTable] = useState({ broj_sati: '0.00', kolicina_dnevnica: '0.00', iznos_dnevnica: '30.00', daily_rate_country: 'HR', total_for_payout: '0.00' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingTask, setEditingTask] = useState(null);
|
||||
@@ -154,6 +158,8 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
|
||||
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
|
||||
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
|
||||
const [editingTravelExpenses, setEditingTravelExpenses] = useState(false);
|
||||
const [savingTravelExpenses, setSavingTravelExpenses] = useState(false);
|
||||
const [editingTaskNote, setEditingTaskNote] = useState(null);
|
||||
const [savingTaskNote, setSavingTaskNote] = useState(false);
|
||||
const [taskNoteError, setTaskNoteError] = useState('');
|
||||
@@ -192,11 +198,12 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([
|
||||
const [contextPayload, order, invoiceItems, additionalCostsPayload, travelExpensesPayload] = await Promise.all([
|
||||
fetchWorkOrderTaskServiceContext(workOrderId),
|
||||
fetchWorkOrderById(workOrderId),
|
||||
fetchWorkOrderInvoices(workOrderId),
|
||||
fetchWorkOrderAdditionalCostsTable(workOrderId),
|
||||
fetchWorkOrderTravelExpensesTable(workOrderId),
|
||||
]);
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -205,6 +212,13 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
setWorkOrder(order);
|
||||
setInvoices(invoiceItems);
|
||||
setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' });
|
||||
setTravelExpensesTable(travelExpensesPayload || contextPayload?.travel_expenses_table || {
|
||||
broj_sati: '0.00',
|
||||
kolicina_dnevnica: '0.00',
|
||||
iznos_dnevnica: '30.00',
|
||||
daily_rate_country: 'HR',
|
||||
total_for_payout: '0.00',
|
||||
});
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
||||
@@ -311,6 +325,24 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTravelExpensesTable = async (tableData) => {
|
||||
if (!workOrderId) return;
|
||||
setSavingTravelExpenses(true);
|
||||
try {
|
||||
const response = await updateWorkOrderTravelExpensesTable(workOrderId, tableData);
|
||||
setTravelExpensesTable(response || {
|
||||
broj_sati: '0.00',
|
||||
kolicina_dnevnica: '0.00',
|
||||
iznos_dnevnica: '30.00',
|
||||
daily_rate_country: 'HR',
|
||||
total_for_payout: '0.00',
|
||||
});
|
||||
setEditingTravelExpenses(false);
|
||||
} finally {
|
||||
setSavingTravelExpenses(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">
|
||||
@@ -338,6 +370,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
>
|
||||
Preuzmi DOCX putnog naloga
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTravelExpenses(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 obračun putnih troškova
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -570,6 +610,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
onClose={() => setEditingAdditionalCosts(false)}
|
||||
onSave={handleSaveAdditionalCostsTable}
|
||||
/>
|
||||
<WorkOrderTravelExpensesModal
|
||||
open={editingTravelExpenses}
|
||||
workOrder={workOrder}
|
||||
tableData={travelExpensesTable}
|
||||
saving={savingTravelExpenses}
|
||||
onClose={() => setEditingTravelExpenses(false)}
|
||||
onSave={handleSaveTravelExpensesTable}
|
||||
/>
|
||||
<WorkOrderServiceNotesModal
|
||||
open={!!editingTaskNote}
|
||||
task={editingTaskNote}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
|
||||
const RATE_OPTIONS = [
|
||||
{ value: 'HR', label: 'Hrvatska', amount: '30.00' },
|
||||
{ value: 'BIH', label: 'BiH', amount: '50.00' },
|
||||
{ value: 'SI', label: 'Slovenija', amount: '80.00' },
|
||||
{ value: 'CG', label: 'Crna Gora', amount: '50.00' },
|
||||
];
|
||||
|
||||
const DEFAULT_ROW = {
|
||||
broj_sati: '0.00',
|
||||
kolicina_dnevnica: '0.00',
|
||||
iznos_dnevnica: '30.00',
|
||||
daily_rate_country: 'HR',
|
||||
};
|
||||
|
||||
function parseAmount(value) {
|
||||
const normalized = String(value || '').trim().replace('€', '').replace(/\s+/g, '').replace(',', '.');
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function toDisplayValue(value) {
|
||||
if (value == null || value === '') return '0.00';
|
||||
const numeric = Number(String(value).replace(',', '.'));
|
||||
return Number.isFinite(numeric) ? numeric.toFixed(2) : String(value);
|
||||
}
|
||||
|
||||
function getRateAmount(country) {
|
||||
const option = RATE_OPTIONS.find((item) => item.value === country);
|
||||
return option ? option.amount : DEFAULT_ROW.iznos_dnevnica;
|
||||
}
|
||||
|
||||
export default function WorkOrderTravelExpensesModal({
|
||||
open,
|
||||
workOrder,
|
||||
tableData,
|
||||
saving = false,
|
||||
onClose,
|
||||
onSave,
|
||||
}) {
|
||||
const [row, setRow] = useState(DEFAULT_ROW);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRow(DEFAULT_ROW);
|
||||
return;
|
||||
}
|
||||
const incoming = tableData && typeof tableData === 'object' ? tableData : {};
|
||||
const country = String(incoming.daily_rate_country || DEFAULT_ROW.daily_rate_country).toUpperCase();
|
||||
const amount = incoming.iznos_dnevnica != null && incoming.iznos_dnevnica !== ''
|
||||
? incoming.iznos_dnevnica
|
||||
: getRateAmount(country);
|
||||
setRow({
|
||||
broj_sati: toDisplayValue(incoming.broj_sati),
|
||||
kolicina_dnevnica: toDisplayValue(incoming.kolicina_dnevnica),
|
||||
iznos_dnevnica: toDisplayValue(amount),
|
||||
daily_rate_country: country,
|
||||
});
|
||||
}, [open, tableData]);
|
||||
|
||||
const total = useMemo(() => (
|
||||
(parseAmount(row.kolicina_dnevnica) * parseAmount(row.iznos_dnevnica)).toFixed(2)
|
||||
), [row]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const updateField = (field, value) => {
|
||||
setRow((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const updateCountry = (country) => {
|
||||
setRow((prev) => ({
|
||||
...prev,
|
||||
daily_rate_country: country,
|
||||
iznos_dnevnica: getRateAmount(country),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSave?.({
|
||||
broj_sati: String(row.broj_sati || '').trim(),
|
||||
kolicina_dnevnica: String(row.kolicina_dnevnica || '').trim(),
|
||||
iznos_dnevnica: String(row.iznos_dnevnica || '').trim(),
|
||||
daily_rate_country: String(row.daily_rate_country || '').trim(),
|
||||
});
|
||||
};
|
||||
|
||||
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-4xl 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">OBRAČUN PUTNIH TROŠKOVA</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="rounded-lg border border-gray-200 bg-white p-4 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
<p className="font-semibold text-gray-800 dark:text-gray-100">Brzi odabir dnevnice</p>
|
||||
<p>Odabir države automatski postavlja iznos dnevnice. Po potrebi iznos možete ručno izmijeniti.</p>
|
||||
</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">Broj sati</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Količina dnevnica</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Država</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Iznos dnevnice</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Ukupan iznos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t border-gray-100 dark:border-gray-700">
|
||||
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400">1</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.broj_sati}
|
||||
onInput={(event) => updateField('broj_sati', 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.kolicina_dnevnica}
|
||||
onInput={(event) => updateField('kolicina_dnevnica', 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">
|
||||
<select
|
||||
value={row.daily_rate_country}
|
||||
onChange={(event) => updateCountry(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"
|
||||
>
|
||||
{RATE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.iznos_dnevnica}
|
||||
onInput={(event) => updateField('iznos_dnevnica', 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">
|
||||
<div className="rounded-lg border border-gray-300 bg-gray-100 px-2.5 py-2 text-right text-xs font-semibold text-gray-800">
|
||||
{total} €
|
||||
</div>
|
||||
</td>
|
||||
</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 obračun'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -737,10 +737,12 @@ export async function fetchTasksByWorkOrder(workOrderId) {
|
||||
|
||||
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return { tasks: [], additional_costs_table: null };
|
||||
return { tasks: [], additional_costs_table: null, travel_expenses_table: null };
|
||||
}
|
||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
|
||||
return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null };
|
||||
return payload && typeof payload === 'object'
|
||||
? payload
|
||||
: { tasks: [], additional_costs_table: null, travel_expenses_table: null };
|
||||
}
|
||||
|
||||
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
|
||||
@@ -765,6 +767,42 @@ export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchWorkOrderTravelExpensesTable(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return {
|
||||
work_order: null,
|
||||
broj_sati: '0.00',
|
||||
kolicina_dnevnica: '0.00',
|
||||
iznos_dnevnica: '30.00',
|
||||
daily_rate_country: 'HR',
|
||||
total_for_payout: '0.00',
|
||||
};
|
||||
}
|
||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`);
|
||||
return payload && typeof payload === 'object'
|
||||
? payload
|
||||
: {
|
||||
work_order: null,
|
||||
broj_sati: '0.00',
|
||||
kolicina_dnevnica: '0.00',
|
||||
iznos_dnevnica: '30.00',
|
||||
daily_rate_country: 'HR',
|
||||
total_for_payout: '0.00',
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWorkOrderTravelExpensesTable(workOrderId, data) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
const payload = await api.put(
|
||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`,
|
||||
data ?? {},
|
||||
);
|
||||
showToast('Obračun putnih troškova je uspješno spremljen.', 'success');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function updateTaskWorkHoursTable(taskId, data) {
|
||||
if (!taskId) {
|
||||
throw new Error('Task ID je obavezan.');
|
||||
|
||||
Reference in New Issue
Block a user