fix: add editable work order travel expenses
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

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:
mariomitte
2026-08-08 03:52:41 +02:00
parent a9ab83c202
commit 8577aaa01b
8 changed files with 697 additions and 35 deletions

View File

@@ -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>
);
}