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 ( ); } return ( !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 (
{/* ── header ── */}

Tablica radnih sati

{task.title || '-'}

{/* ── info banner ── */}
Put (od–do): unesi Put od/do → Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana. Rad (od–do): unesi Vr. rada od/do → Sati rada se računaju automatski; Km, Put, Mj. polaska/dolaska su blokirani. Pauza umanjuje izračunate sate u oba slučaja.
{/* ── toolbar ── */}
{/* ── mobile cards (< xl) ── */}
{rows.length === 0 && (

Nema unesenih redaka.

)} {rows.map((row, idx) => (
#{idx + 1}
{FIELD_CONFIGS.map((f) => ( ))}
))} {rows.length > 0 && (
UKUPNO — Sati rada: {totals.work} h · Sati puta: {totals.travel} h · Km vozila: {totals.km} km
)}
{/* ── desktop table (≥ xl) ── */}
{FIELD_CONFIGS.map((f) => ( ))} {rows.length === 0 && ( )} {rows.map((row, idx) => ( {FIELD_CONFIGS.map((f) => ( ))} ))} {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 */ )}
# {f.label} {f.computed && (auto)} Akcija
Nema unesenih redaka.
{idx + 1} {renderCell(row, idx, f)}
UKUPNO Sati rada:{totals.work} h Sati puta:{totals.travel} h Km:{totals.km}
{/* ── footer ── */}
); }