interaktivna Tablica radnih sati, db
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

This commit is contained in:
mariomitte
2026-07-13 00:36:28 +02:00
parent 70151f1225
commit 3ad536a23e
22 changed files with 1629 additions and 174 deletions

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'preact/hooks';
import { createTask, getStatusLabel } from '../../stores/taskStore';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
import ModalShell from '../ui/ModalShell';
const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
const INITIAL_FORM = {
title: '',
@@ -15,48 +16,107 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
const [form, setForm] = useState(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [templateMode, setTemplateMode] = useState(false);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateId, setTemplateId] = useState('');
const [templates, setTemplates] = useState([]);
const selectedTemplate = useMemo(
() => templates.find((template) => String(template.id) === String(templateId)) || null,
[templates, templateId]
);
useEffect(() => {
if (!open) {
setForm(INITIAL_FORM);
setSubmitting(false);
setError('');
setTemplateMode(false);
setTemplateId('');
setTemplates([]);
setTemplateLoading(false);
}
}, [open]);
useEffect(() => {
if (!selectedTemplate) return;
setForm((prev) => ({
...prev,
title: selectedTemplate.title || '',
description: selectedTemplate.description || '',
status: 'aktivan',
work_order: '',
}));
}, [selectedTemplate]);
if (!open) return null;
function setField(name, value) {
setForm((prev) => ({ ...prev, [name]: value }));
}
async function enableTemplateMode() {
if (templateMode) {
setTemplateMode(false);
setTemplateId('');
setForm(INITIAL_FORM);
return;
}
setError('');
setTemplateLoading(true);
try {
const fetched = await fetchTaskTemplates();
setTemplates(fetched);
setTemplateMode(true);
} catch (err) {
setError(err?.message || 'Neuspješno učitavanje templatea.');
} finally {
setTemplateLoading(false);
}
}
async function submit(event) {
event.preventDefault();
setError('');
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if (form.status === 'neaktivan' && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
if (!contextCrane?.id) {
setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
return;
}
setSubmitting(true);
try {
await createTask({
let payload;
if (templateMode) {
if (!templateId) {
setError('Odaberite template za unos zadatka.');
return;
}
payload = {
template_id: templateId,
vehicle: contextCrane.id,
work_order: null,
};
} else {
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if ((form.status === 'neaktivan' || form.status === 'zavrsen') && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
payload = {
title,
description: String(form.description || '').trim(),
status: form.status || 'aktivan',
vehicle: contextCrane.id,
work_order: form.work_order || null,
});
};
}
setSubmitting(true);
try {
await createTask(payload);
await onSuccess?.();
onClose?.();
} catch (err) {
@@ -67,15 +127,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
}
return (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
onClick={onClose}
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
>
<div className="flex min-h-full items-start justify-center">
<div
className="w-full max-w-xl max-h-[calc(100vh-2rem)] overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="w-full flex flex-col">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Novi radni zadatak</h3>
@@ -83,26 +141,60 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={enableTemplateMode}
disabled={templateLoading || submitting}
className="rounded-md border border-border-hairline px-3 py-1 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
{templateMode ? 'Ručni unos' : 'Unos iz templatea'}
</button>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
</div>
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
{templateMode && (
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Template zadatka *</span>
<select
value={templateId}
onChange={(event) => setTemplateId(event.currentTarget.value)}
disabled={submitting || templateLoading}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Odaberite template </option>
{templates.map((template) => (
<option key={template.id} value={String(template.id)}>
{template.title}
</option>
))}
</select>
{selectedTemplate && (
<span className="text-[11px] text-text-muted">
Generirat će se do 10 servisnih zapisa iz predloška (vidljivo u tablici Servisni zapisi).
</span>
)}
</label>
)}
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naslov zadatka *</span>
<input
type="text"
value={form.title}
onInput={(event) => setField('title', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
required
required={!templateMode}
/>
</label>
@@ -112,7 +204,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
rows={3}
value={form.description}
onInput={(event) => setField('description', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
@@ -123,7 +215,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<select
value={form.status}
onChange={(event) => setField('status', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
{STATUS_OPTIONS.map((status) => (
@@ -139,7 +231,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<select
value={form.work_order}
onChange={(event) => setField('work_order', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Bez povezanog naloga </option>
@@ -149,6 +241,11 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
</option>
))}
</select>
{templateMode && (
<span className="text-[11px] text-text-muted">
Kod template unosa nalog nije obavezan i backend ga neće automatski povezati.
</span>
)}
{workOrders.length === 0 && (
<span className="text-[11px] text-amber-600">
Nema dostupnih naloga za odabranu dizalicu.
@@ -175,14 +272,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
disabled={submitting}
disabled={submitting || templateLoading}
>
{submitting ? 'Spremanje...' : 'Kreiraj radni zadatak'}
</button>
</div>
</form>
</div>
</div>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,476 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
// ─── helpers ────────────────────────────────────────────────────────────────
function parseTime(str) {
if (!str || typeof str !== 'string') return null;
const match = str.trim().match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const h = parseInt(match[1], 10);
const m = parseInt(match[2], 10);
if (h > 23 || m > 59) return null;
return h + m / 60;
}
function calcHours(fromStr, toStr) {
const from = parseTime(fromStr);
const to = parseTime(toStr);
if (from === null || to === null) return null;
let diff = to - from;
if (diff < 0) diff += 24; // overnight
return Math.max(0, diff);
}
function parseNum(value) {
if (value == null || value === '') return 0;
const n = Number(String(value).replace(',', '.'));
return Number.isFinite(n) ? n : 0;
}
function fmtH(h) { return h.toFixed(1).replace('.', ','); }
/** Converts decimal hours back to "HH:MM" string. */
function formatTime(decimalHours) {
if (!Number.isFinite(decimalHours)) return '';
let h = ((decimalHours % 24) + 24) % 24;
const hours = Math.floor(h);
let mins = Math.round((h - hours) * 60);
if (mins === 60) return `${String(hours + 1).padStart(2, '0')}:00`;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/** Returns 'travel' | 'work' | 'empty' for a given row. */
function rowMode(row) {
if (row?.travel_time_from?.trim() || row?.travel_time_to?.trim()) return 'travel';
if (row?.work_time_from?.trim() || row?.work_time_to?.trim()) return 'work';
return 'empty';
}
/**
* Applies mutual-exclusion and auto-calculation to a single row.
* Travel mode → clears work fields, computes travel_hours from times break.
* Work mode → clears travel fields + vehicle_km, computes work_hours.
* Empty mode → clears both calculated totals.
*/
function computeRow(row) {
const r = { ...row };
const mode = rowMode(r);
if (mode === 'travel') {
r.work_time_from = '';
r.work_time_to = '';
r.work_hours = '';
const raw = calcHours(r.travel_time_from, r.travel_time_to);
r.travel_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
} else if (mode === 'work') {
r.travel_time_from = '';
r.travel_time_to = '';
r.travel_hours = '';
r.vehicle_km = '';
const raw = calcHours(r.work_time_from, r.work_time_to);
r.work_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
} else {
r.work_hours = '';
r.travel_hours = '';
}
return r;
}
const INTERNAL = ['_groupId', '_role'];
function stripInternal(row) { const r = { ...row }; for (const f of INTERNAL) delete r[f]; return r; }
let _gidSeq = 0;
function newGroupId() { _gidSeq += 1; return `g${_gidSeq}`; }
// ─── constants ───────────────────────────────────────────────────────────────
const EMPTY_ROW = {
day: '', date: '',
work_time_from: '', work_time_to: '',
travel_time_from: '', travel_time_to: '',
break_hours: '',
work_hours: '', travel_hours: '',
departure_place: '', arrival_place: '',
vehicle_km: '',
};
/**
* computed: true → value is auto-calculated, never editable directly.
* travelOnly: true → disabled when mode === 'work'.
* workOnly: true → disabled when mode === 'travel'.
*/
const FIELD_CONFIGS = [
{ key: 'day', label: 'Dan', type: 'select' },
{ key: 'date', label: 'Datum', type: 'text', placeholder: 'dd.mm.gggg' },
{ key: 'work_time_from', label: 'Vr. rada od', type: 'time', workOnly: true },
{ key: 'work_time_to', label: 'Vr. rada do', type: 'time', workOnly: true },
{ key: 'travel_time_from', label: 'Put od', type: 'time', travelOnly: true },
{ key: 'travel_time_to', label: 'Put do', type: 'time', travelOnly: true },
{ key: 'break_hours', label: 'Pauza h', type: 'number', step: '0.5', min: '0', placeholder: '0.0' },
{ key: 'work_hours', label: 'Sati rada', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
{ key: 'travel_hours', label: 'Sati puta', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
{ key: 'departure_place', label: 'Mj. polaska', type: 'text', placeholder: 'Zagreb', travelOnly: true },
{ key: 'arrival_place', label: 'Mj. dolaska', type: 'text', placeholder: 'npr. Split', travelOnly: true },
{ key: 'vehicle_km', label: 'Km vozila', type: 'number', step: '1', min: '0', placeholder: '0', travelOnly: true },
];
const DAY_OPTIONS = ['', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB', 'NED'];
const FB_INPUT = [
'block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs',
'text-gray-900 placeholder-gray-400',
'focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none',
'dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400',
'dark:focus:border-blue-500 dark:focus:ring-blue-500',
'transition-colors duration-150',
].join(' ');
const FB_MUTED = [
'block w-full rounded-lg border border-gray-200 bg-gray-100 px-2.5 py-2 text-xs',
'text-gray-400 cursor-default select-none',
'dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500',
].join(' ');
// ─── component ───────────────────────────────────────────────────────────────
export default function TaskWorkHoursTableModal({
open,
task,
defaultLocation = '',
saving = false,
onClose,
onSave,
}) {
const [rows, setRows] = useState([]);
useEffect(() => {
if (!open) { setRows([]); return; }
const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : [];
setRows(
incoming.length > 0
? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) }))
: [{ ...EMPTY_ROW }, { ...EMPTY_ROW }, { ...EMPTY_ROW }],
);
}, [open, task?.id, task?.work_hours_table]);
if (!open || !task) return null;
// ── derived totals ─────────────────────────────────────────────────────
const totals = useMemo(() => {
const travel = rows.reduce((s, r) => s + parseNum(r.travel_hours), 0);
const work = rows.reduce((s, r) => s + parseNum(r.work_hours), 0);
const km = rows.reduce((s, r) => s + parseNum(r.vehicle_km), 0);
return {
travel: fmtH(travel),
work: fmtH(work),
km: Number.isInteger(km) ? String(km) : km.toFixed(1),
};
}, [rows]);
// ── row mutation ───────────────────────────────────────────────────────
const updateRowField = (index, field, value) => {
setRows((prev) => {
// Step 1: update the changed row with full mutual-exclusion + calc
let updated = prev.map((row, i) => {
if (i !== index) return row;
return computeRow({ ...row, [field]: value });
});
const changed = updated[index];
const gid = changed?._groupId;
const role = changed?._role;
if (!gid) return updated;
// Step 2: departure row changes → sync return row
if (role === 'departure') {
updated = updated.map((row, i) => {
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
let returnRow = { ...row };
// Mirror day/date always
if (field === 'day' || field === 'date') {
returnRow[field] = value;
}
// Mirror break_hours
if (field === 'break_hours') {
returnRow.break_hours = value;
}
// Mirror vehicle_km
if (field === 'vehicle_km') {
returnRow.vehicle_km = value;
}
// Mirror travel times + recalculate return travel_to from duration
if (field === 'travel_time_from' || field === 'travel_time_to') {
returnRow.travel_time_from = changed.travel_time_from;
// Recalculate return's travel_to: return departs when work ends (travel_time_from stays)
// and duration equals departure trip duration
const depDuration = calcHours(changed.travel_time_from, changed.travel_time_to);
if (depDuration !== null && returnRow.travel_time_from) {
const retFrom = parseTime(returnRow.travel_time_from);
if (retFrom !== null) returnRow.travel_time_to = formatTime(retFrom + depDuration);
}
}
// Mirror places (swapped): arrival of departure → departure of return
if (field === 'arrival_place') {
returnRow.departure_place = value;
}
if (field === 'departure_place') {
returnRow.arrival_place = value;
}
return computeRow(returnRow);
});
}
// Step 3: work row's work_time_to → fill return row's travel_time_from
// and recalculate return row's travel_time_to using departure duration
if (role === 'work' && field === 'work_time_to') {
const depRow = updated.find(r => r._groupId === gid && r._role === 'departure');
const depDuration = depRow ? calcHours(depRow.travel_time_from, depRow.travel_time_to) : null;
updated = updated.map((row, i) => {
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
const newFrom = value; // Put od = when work ends
const newTo = (depDuration !== null && parseTime(newFrom) !== null)
? formatTime(parseTime(newFrom) + depDuration)
: row.travel_time_to;
return computeRow({ ...row, travel_time_from: newFrom, travel_time_to: newTo });
});
}
return updated;
});
};
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
const addThreeRows = () => {
const today = new Date();
const dd = String(today.getDate()).padStart(2, '0');
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dateStr = `${dd}.${mm}.${today.getFullYear()}`;
const dayLabels = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB'];
const dayLabel = dayLabels[today.getDay()];
const gid = newGroupId();
const loc = defaultLocation || '';
setRows((prev) => [
...prev,
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' },
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' },
]);
};
const removeRow = (i) => setRows((prev) => prev.filter((_, idx) => idx !== i));
const handleSave = async () => {
await onSave?.({ rows: rows.map(stripInternal) });
};
// ── field rendering ────────────────────────────────────────────────────
const isDisabled = (row, f) => {
const mode = rowMode(row);
if (f.computed) return true;
if (f.travelOnly && mode === 'work') return true;
if (f.workOnly && mode === 'travel') return true;
return false;
};
const renderCell = (row, idx, f) => {
const disabled = isDisabled(row, f);
const cls = disabled ? FB_MUTED : FB_INPUT;
if (f.type === 'select') {
return (
<select
value={row[f.key] || ''}
onChange={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
disabled={disabled}
className={cls}
>
{DAY_OPTIONS.map((o) => <option key={o || '_'} value={o}>{o || '-'}</option>)}
</select>
);
}
return (
<input
type={f.type}
value={row[f.key] || ''}
onInput={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
readOnly={f.computed}
disabled={disabled}
className={cls}
placeholder={f.placeholder || ''}
step={f.step}
min={f.min}
/>
);
};
// ── render ─────────────────────────────────────────────────────────────
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-[96vw] 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">
{/* ── header ── */}
<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">Tablica radnih sati</h3>
<p className="text-xs text-text-muted">{task.title || '-'}</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">
{/* ── info banner ── */}
<div className="flex flex-wrap gap-x-4 gap-y-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
<span><span className="font-semibold">Put (oddo):</span> unesi Put od/do Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana.</span>
<span><span className="font-semibold">Rad (oddo):</span> unesi Vr. rada od/do Sati rada se računaju automatski; Km, Put, Mj. polaska/dolaska su blokirani.</span>
<span><span className="font-semibold">Pauza</span> umanjuje izračunate sate u oba slučaja.</span>
</div>
{/* ── toolbar ── */}
<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>
<button type="button" onClick={addThreeRows}
className="inline-flex items-center gap-1 rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50">
+ Dodaj standardna 3 retka (Put / Rad / Povratak)
</button>
</div>
{/* ── mobile cards (< xl) ── */}
<div className="space-y-3 xl:hidden">
{rows.length === 0 && (
<p className="text-sm text-gray-400 dark:text-gray-500">Nema unesenih redaka.</p>
)}
{rows.map((row, idx) => (
<div key={idx} className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-bold text-gray-500 dark:text-gray-400">#{idx + 1}</span>
<button type="button" onClick={() => removeRow(idx)}
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Obriši
</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{FIELD_CONFIGS.map((f) => (
<label key={f.key} className="flex flex-col gap-0.5">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">{f.label}</span>
{renderCell(row, idx, f)}
</label>
))}
</div>
</div>
))}
{rows.length > 0 && (
<div className="rounded-lg border border-gray-300 bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200">
UKUPNO
<span className="ml-2">Sati rada: <span className="text-green-700 dark:text-green-400">{totals.work} h</span></span>
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
<span>Sati puta: <span className="text-blue-700 dark:text-blue-400">{totals.travel} h</span></span>
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
<span>Km vozila: <span className="text-gray-800 dark:text-gray-100">{totals.km} km</span></span>
</div>
)}
</div>
{/* ── desktop table (≥ xl) ── */}
<div className="hidden xl:block">
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700"> <table className="min-w-[1580px] 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 dark:text-gray-500">#</th>
{FIELD_CONFIGS.map((f) => (
<th key={f.key} className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap">
{f.label}
{f.computed && <span className="ml-1 text-[9px] font-normal text-indigo-400">(auto)</span>}
</th>
))}
<th className="px-2 py-2.5 text-gray-400 dark:text-gray-500">Akcija</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={FIELD_CONFIGS.length + 2}
className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
Nema unesenih redaka.
</td>
</tr>
)}
{rows.map((row, idx) => (
<tr key={idx}
className="border-t border-gray-100 odd:bg-white even:bg-gray-50/60 hover:bg-blue-50/30 dark:border-gray-700 dark:odd:bg-gray-900 dark:even:bg-gray-800/60 dark:hover:bg-blue-900/10">
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400 dark:text-gray-500">{idx + 1}</td>
{FIELD_CONFIGS.map((f) => (
<td key={f.key} className="px-1 py-1 align-middle">
{renderCell(row, idx, f)}
</td>
))}
<td className="px-1 py-1 align-middle">
<button type="button" onClick={() => removeRow(idx)}
className="whitespace-nowrap rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Obriši
</button>
</td>
</tr>
))}
{rows.length > 0 && (
/* cols: # day date work_from work_to travel_from travel_to break → 8 cols
then: work_hours travel_hours departure arrival vehicle_km action */
<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={8} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati rada:</span>{totals.work} h
</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati puta:</span>{totals.travel} h
</td>
<td colSpan={2} className="px-2 py-2" />
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Km:</span>{totals.km}
</td>
<td className="px-2 py-2" />
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* ── footer ── */}
<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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
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 dark:bg-blue-700 dark:hover:bg-blue-600">
{saving ? 'Spremam…' : 'Spremi tablicu radnih sati'}
</button>
</div>
</div>
</ModalShell>
);
}

View File

@@ -7,11 +7,13 @@ import {
fetchWorkOrderById,
fetchWorkOrderInvoices,
fetchWorkOrderTaskServiceContext,
updateTaskWorkHoursTable,
} from '../../stores/fleetDashboardStore';
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatEntityCode } from '../../lib/displayIds';
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') {
@@ -53,6 +55,8 @@ export default function WorkOrderInvoicesPdfPage() {
const [taskContext, setTaskContext] = useState({ tasks: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingTask, setEditingTask] = useState(null);
const [savingWorkHours, setSavingWorkHours] = useState(false);
useEffect(() => {
hydrateAuthFromStorage();
@@ -149,6 +153,25 @@ export default function WorkOrderInvoicesPdfPage() {
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);
}
};
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">
@@ -196,6 +219,15 @@ export default function WorkOrderInvoicesPdfPage() {
<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-2">
<button
type="button"
onClick={() => setEditingTask(task)}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Uredi tablicu radnih sati
</button>
</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>
@@ -281,6 +313,14 @@ export default function WorkOrderInvoicesPdfPage() {
</div>
)}
</div>
<TaskWorkHoursTableModal
open={!!editingTask}
task={editingTask}
defaultLocation={workOrder?.location || ''}
saving={savingWorkHours}
onClose={() => setEditingTask(null)}
onSave={handleSaveWorkHoursTable}
/>
</section>
);
}

View File

@@ -692,6 +692,15 @@ export async function fetchWorkOrderTaskServiceContext(workOrderId) {
return payload && typeof payload === 'object' ? payload : { tasks: [] };
}
export async function updateTaskWorkHoursTable(taskId, data) {
if (!taskId) {
throw new Error('Task ID je obavezan.');
}
const payload = await api.put(`tasks/tasks/${encodeURIComponent(taskId)}/work-hours-table/`, data ?? null);
showToast('Tablica radnih sati je uspješno spremljena.', 'success');
return payload;
}
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');

View File

@@ -1,14 +1,17 @@
// src/stores/taskStore.js
// src/stores/taskStore.js
import { atom } from 'nanostores';
import { api } from '../services/apiClient';
import { showToast } from './toastStore';
export const $tasks = atom([]);
export const $tasksLoading = atom(false);
export const $taskTemplates = atom([]);
export const $taskTemplatesLoading = atom(false);
const STATUS_LABELS = {
aktivan: 'Aktivan',
servis: 'U tijeku',
zavrsen: 'Završen',
neaktivan: 'Otkazano',
};
@@ -35,6 +38,23 @@ export async function fetchTasks() {
}
}
export async function fetchTaskTemplates() {
$taskTemplatesLoading.set(true);
try {
const data = await api.get('tasks/tasks/templates/');
const templates = Array.isArray(data) ? data : (data?.results ?? []);
$taskTemplates.set(templates);
return templates;
} catch (error) {
if (error?.status !== 401) {
showToast('Greška pri dohvatu predložaka zadataka.', 'error');
}
throw error;
} finally {
$taskTemplatesLoading.set(false);
}
}
export async function createTask(data) {
const response = await api.post('tasks/tasks/', data);
$tasks.set([response, ...$tasks.get()]);