Files
004.erp.generic/frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
mariomitte 3ad536a23e
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
interaktivna Tablica radnih sati, db
2026-07-13 00:36:28 +02:00

477 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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