interaktivna Tablica radnih sati, db
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
476
frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
Normal file
476
frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
Normal 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 (od–do):</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 (od–do):</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
Reference in New Issue
Block a user