import { useEffect, useMemo, useState } from 'preact/hooks'; import { getStatusLabel } from '../../stores/taskStore'; import { downloadMonthlyServiceTasksArchive, downloadMonthlyCostsReport, downloadMonthlyServiserReport, downloadMonthlyWorkOrdersArchive, fetchMonthlyCostInvoices, fetchMonthlyServicerEntries, upsertMonthlyServicerEntry, } from '../../stores/fleetDashboardStore'; import LeftSlideDrawer from '../layout/LeftSlideDrawer'; import { getTaskCraneKind, getTaskCraneOwner, getTaskCraneSerial } from '../../lib/taskCraneDisplay'; const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Ce', 'Pe', 'Su', 'Ne']; const MONTH_NAMES = [ 'Sijecanj', 'Veljaca', 'Ozujak', 'Travanj', 'Svibanj', 'Lipanj', 'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac', ]; const SERVICER_HEADERS = ['DATUM', 'OPIS POSLA', 'BR. DIZALICE', 'KOMITENT', 'MJESTO RADA', 'POCETAK RADA', 'KRAJ RADA', 'REDOVAN RAD (h)', 'PREKOVREMENI (h)', 'RADNI NALOG']; const COST_HEADERS = ['DATUM', 'NAZIV RACUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG']; function toDateKey(value) { const text = String(value || '').trim(); if (!text) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text; const croatian = text.match(/^(\d{2})\.(\d{2})\.(\d{4})\.?$/); if (croatian) { return `${croatian[3]}-${croatian[2]}-${croatian[1]}`; } const parsed = new Date(text); if (Number.isNaN(parsed.getTime())) return null; return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}`; } function formatDayLabel(year, month, day) { return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`; } function formatDateFromKey(dateKey) { const [year, month, day] = String(dateKey || '').split('-'); if (!year || !month || !day) return '-'; return `${day}.${month}.${year}.`; } function formatTime(value) { if (!value) return '-'; if (typeof value === 'string' && /^\d{2}:\d{2}/.test(value)) { return value.slice(0, 5); } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return '-'; return parsed.toLocaleTimeString('hr-HR', { hour: '2-digit', minute: '2-digit' }); } function toTimeInputValue(value) { const formatted = formatTime(value); return formatted === '-' ? '' : formatted; } function calculateHours(startTime, endTime) { if (!startTime || !endTime) return null; const [startHour, startMinute] = String(startTime).split(':').map((part) => Number(part)); const [endHour, endMinute] = String(endTime).split(':').map((part) => Number(part)); if (![startHour, startMinute, endHour, endMinute].every((value) => Number.isFinite(value))) return null; const startTotal = (startHour * 60) + startMinute; const endTotal = (endHour * 60) + endMinute; if (endTotal <= startTotal) return null; return ((endTotal - startTotal) / 60).toFixed(2).replace(/\.?0+$/, ''); } function formatHourValue(value, fallback = '-') { if (value == null || value === '') return fallback; const numeric = Number(value); if (!Number.isFinite(numeric)) return String(value); if (Number.isInteger(numeric)) return String(numeric); return numeric.toFixed(2).replace(/\.?0+$/, ''); } function joinUnique(values) { return Array.from(new Set(values.filter(Boolean).map((item) => String(item).trim()).filter(Boolean))).join(', '); } function parseReportHours(value) { if (value == null || value === '') return 0; const numeric = Number(String(value).replace(',', '.')); return Number.isFinite(numeric) ? numeric : 0; } function parseReportDateTime(dateKey, value) { if (value == null || value === '') return null; if (value instanceof Date) { return new Date(value.getTime()); } const text = String(value).trim(); if (!text) return null; const timeMatch = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (dateKey && timeMatch) { const hours = String(timeMatch[1]).padStart(2, '0'); const minutes = timeMatch[2]; const seconds = timeMatch[3] || '00'; return new Date(`${dateKey}T${hours}:${minutes}:${seconds}`); } const parsed = new Date(text); return Number.isNaN(parsed.getTime()) ? null : parsed; } function getTaskWorkHoursEntries(task, workOrdersById) { const tableData = task?.work_hours_table?.data ?? task?.work_hours_table ?? null; const rows = Array.isArray(tableData?.rows) ? tableData.rows : []; const workOrder = workOrdersById.get(String(task?.work_order || '')); const base = { title: task?.title || '', serial: getTaskCraneSerial(task) || '', client: getTaskCraneOwner(task) || '', location: workOrder?.location || '', workOrderLabel: task?.work_order_label || '', }; const entries = []; for (const row of rows) { if (!row || typeof row !== 'object') continue; const dateKey = toDateKey(row.date) || toDateKey(task?.scheduled_date); if (!dateKey) continue; const startAt = parseReportDateTime(dateKey, row.work_time_from || row.travel_time_from || ''); const endAt = parseReportDateTime(dateKey, row.work_time_to || row.travel_time_to || ''); entries.push({ ...base, dateKey, startAt, endAt, totalHours: parseReportHours(row.work_hours) + parseReportHours(row.travel_hours), }); } if (entries.length > 0) { return entries; } const travelStart = workOrder?.travel_start_at; const travelEnd = workOrder?.travel_end_at; if (travelStart && travelEnd) { const startAt = parseReportDateTime(null, travelStart); const endAt = parseReportDateTime(null, travelEnd); const dateKey = toDateKey(task?.scheduled_date) || toDateKey(travelStart); const totalHours = startAt && endAt ? Math.max(0, (endAt.getTime() - startAt.getTime()) / 3600000) : 0; if (dateKey) { entries.push({ ...base, dateKey, startAt, endAt, totalHours, }); } } return entries; } export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders = [], onTaskClick }) { const [viewYear, setViewYear] = useState(() => new Date().getFullYear()); const [viewMonth, setViewMonth] = useState(() => new Date().getMonth()); const [selectedDay, setSelectedDay] = useState(null); const [reportType, setReportType] = useState(null); const [downloading, setDownloading] = useState(false); const [manualEntries, setManualEntries] = useState([]); const [costInvoices, setCostInvoices] = useState([]); const [loadingManualEntries, setLoadingManualEntries] = useState(false); const [loadingCostInvoices, setLoadingCostInvoices] = useState(false); const [manualEntryTarget, setManualEntryTarget] = useState(null); const [manualEntryDraft, setManualEntryDraft] = useState(null); const [manualEntryError, setManualEntryError] = useState(''); const [savingManualEntry, setSavingManualEntry] = useState(false); const [bulkDownloadOpen, setBulkDownloadOpen] = useState(false); const [downloadingAllTasks, setDownloadingAllTasks] = useState(false); const [downloadingAllWorkOrders, setDownloadingAllWorkOrders] = useState(false); const reportOpen = reportType !== null; const panelWidth = reportOpen ? 920 : 300; const tasksByDate = useMemo(() => { const map = {}; for (const task of tasks) { const key = toDateKey(task.scheduled_date); if (!key) continue; if (!map[key]) map[key] = []; map[key].push(task); } return map; }, [tasks]); const notesByDate = useMemo(() => { const map = {}; for (const note of notes) { const key = toDateKey(note.note_date); if (!key) continue; if (!map[key]) map[key] = []; map[key].push(note); } return map; }, [notes]); const workOrdersById = useMemo(() => { const map = new Map(); for (const workOrder of workOrders) { if (workOrder?.id) { map.set(String(workOrder.id), workOrder); } } return map; }, [workOrders]); const manualEntriesByDate = useMemo(() => { const map = {}; for (const entry of manualEntries) { const key = toDateKey(entry.entry_date); if (key) { map[key] = entry; } } return map; }, [manualEntries]); const calendarDays = useMemo(() => { const firstDay = new Date(viewYear, viewMonth, 1); let startDow = firstDay.getDay(); startDow = (startDow + 6) % 7; const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const cells = []; for (let i = 0; i < startDow; i += 1) cells.push(null); for (let d = 1; d <= daysInMonth; d += 1) cells.push(d); return cells; }, [viewYear, viewMonth]); useEffect(() => { if (!reportOpen || reportType !== 'servicer') return undefined; let active = true; setLoadingManualEntries(true); fetchMonthlyServicerEntries(viewYear, viewMonth + 1) .then((rows) => { if (!active) return; setManualEntries(Array.isArray(rows) ? rows : []); }) .catch(() => { if (!active) return; setManualEntries([]); }) .finally(() => { if (!active) return; setLoadingManualEntries(false); }); return () => { active = false; }; }, [reportOpen, reportType, viewYear, viewMonth]); useEffect(() => { if (!reportOpen || reportType !== 'costs') return undefined; let active = true; setLoadingCostInvoices(true); fetchMonthlyCostInvoices(viewYear, viewMonth + 1) .then((rows) => { if (!active) return; setCostInvoices(Array.isArray(rows) ? rows : []); }) .catch(() => { if (!active) return; setCostInvoices([]); }) .finally(() => { if (!active) return; setLoadingCostInvoices(false); }); return () => { active = false; }; }, [reportOpen, reportType, viewYear, viewMonth]); const reportTaskEntriesByDate = useMemo(() => { const map = {}; for (const task of tasks) { for (const entry of getTaskWorkHoursEntries(task, workOrdersById)) { if (!map[entry.dateKey]) map[entry.dateKey] = []; map[entry.dateKey].push(entry); } } return map; }, [tasks, workOrdersById]); const servicerRows = useMemo(() => { if (reportType !== 'servicer') return []; const rows = []; const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const monthPrefix = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`; for (let day = 1; day <= daysInMonth; day += 1) { const key = `${monthPrefix}-${String(day).padStart(2, '0')}`; const dayEntries = reportTaskEntriesByDate[key] ?? []; const manualEntry = manualEntriesByDate[key]; if (dayEntries.length > 0) { const titles = []; const serials = []; const clients = []; const locations = []; const workOrderLabels = []; const startTimes = []; const endTimes = []; let totalHours = 0; for (const entry of dayEntries) { if (entry.title) titles.push(entry.title); if (entry.serial) serials.push(entry.serial); if (entry.client) clients.push(entry.client); if (entry.location) locations.push(entry.location); if (entry.workOrderLabel) workOrderLabels.push(entry.workOrderLabel); if (entry.startAt) startTimes.push(entry.startAt); if (entry.endAt) endTimes.push(entry.endAt); totalHours += Number(entry.totalHours || 0); } const regularHours = 8; const overtimeHours = Math.max(0, totalHours - regularHours); rows.push({ key, clickable: false, source: 'task', cells: [ formatDateFromKey(key), joinUnique(titles) || '-', joinUnique(serials) || '-', joinUnique(clients) || '-', joinUnique(locations) || '-', startTimes.length ? formatTime(new Date(Math.min(...startTimes.map((item) => item.getTime())))) : '-', endTimes.length ? formatTime(new Date(Math.max(...endTimes.map((item) => item.getTime())))) : '-', formatHourValue(regularHours, '0'), formatHourValue(overtimeHours, '0'), joinUnique(workOrderLabels) || '-', ], }); continue; } if (manualEntry) { rows.push({ key, clickable: true, source: manualEntry.entry_type, cells: [ formatDateFromKey(key), manualEntry.description || '-', '-', '-', manualEntry.location || '-', formatTime(manualEntry.start_time), formatTime(manualEntry.end_time), formatHourValue(manualEntry.regular_hours, '0'), formatHourValue(manualEntry.overtime_hours, '0'), '-', ], }); continue; } rows.push({ key, clickable: true, source: 'empty', cells: [formatDateFromKey(key), 'Klikni za unos', '-', '-', '-', '-', '-', '-', '0', '-'], }); } return rows; }, [manualEntriesByDate, reportTaskEntriesByDate, reportType, viewMonth, viewYear]); const costsRows = useMemo(() => { if (reportType !== 'costs') return []; return costInvoices.map((invoice) => ([ invoice?.datum ? new Date(invoice.datum).toLocaleDateString('hr-HR') : '-', invoice?.naziv_racuna || '-', invoice?.lokacija || '-', invoice?.opis || '-', invoice?.work_order_display_code || '-', ])); }, [costInvoices, reportType]); const selectedDayKey = selectedDay ? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}` : null; const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : []; const selectedDayNotes = selectedDayKey ? (notesByDate[selectedDayKey] ?? []) : []; const today = new Date(); const totalScheduled = Object.values(tasksByDate).flat().length; const totalNoteReminders = Object.values(notesByDate).flat().length; function prevMonth() { setSelectedDay(null); if (viewMonth === 0) { setViewYear((y) => y - 1); setViewMonth(11); return; } setViewMonth((m) => m - 1); } function nextMonth() { setSelectedDay(null); if (viewMonth === 11) { setViewYear((y) => y + 1); setViewMonth(0); return; } setViewMonth((m) => m + 1); } function handleDayClick(day) { setSelectedDay((prev) => (prev === day ? null : day)); } function handleTaskClick(task, closePanel) { onTaskClick?.(task); closePanel?.(); } function toggleReport(type) { setReportType((prev) => (prev === type ? null : type)); setManualEntryTarget(null); if (type !== 'servicer') { setBulkDownloadOpen(false); } } async function handleDownload() { if (downloading) return; setDownloading(true); try { if (reportType === 'servicer') { await downloadMonthlyServiserReport(viewYear, viewMonth + 1); } else if (reportType === 'costs') { await downloadMonthlyCostsReport(viewYear, viewMonth + 1); } } finally { setDownloading(false); } } async function handleDownloadAllTasksArchive() { if (downloadingAllTasks) return; setBulkDownloadOpen(false); setDownloadingAllTasks(true); try { await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1); } finally { setDownloadingAllTasks(false); } } async function handleDownloadAllWorkOrdersArchive() { if (downloadingAllWorkOrders) return; setBulkDownloadOpen(false); setDownloadingAllWorkOrders(true); try { await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1); } finally { setDownloadingAllWorkOrders(false); } } function handleServicerRowClick(row) { if (!row?.clickable) return; setManualEntryTarget(row); setManualEntryDraft(null); setManualEntryError(''); } function startManualEntry(entryType) { if (!manualEntryTarget) return; const isOffice = entryType === 'office'; setManualEntryError(''); setManualEntryDraft({ entryType, startTime: isOffice ? '08:00' : '', endTime: isOffice ? '16:00' : '', }); } async function handleSaveManualEntry() { if (!manualEntryTarget || !manualEntryDraft || savingManualEntry) return; const { entryType, startTime, endTime } = manualEntryDraft; if (startTime && endTime && !calculateHours(startTime, endTime)) { setManualEntryError('Kraj rada mora biti nakon početka rada.'); return; } setManualEntryError(''); setSavingManualEntry(true); try { const saved = await upsertMonthlyServicerEntry({ entry_date: manualEntryTarget.key, entry_type: entryType, start_time: startTime || undefined, end_time: endTime || undefined, }); setManualEntries((prev) => { const next = Array.isArray(prev) ? [...prev] : []; const index = next.findIndex((item) => toDateKey(item.entry_date) === toDateKey(saved.entry_date)); if (index >= 0) { next[index] = saved; } else { next.push(saved); } return next.sort((a, b) => String(a.entry_date || '').localeCompare(String(b.entry_date || ''))); }); setManualEntryTarget(null); setManualEntryDraft(null); setManualEntryError(''); } finally { setSavingManualEntry(false); } } return ( {({ close }) => (
{MONTH_NAMES[viewMonth]} {viewYear}
{WEEKDAY_LABELS.map((label) => (
{label}
))}
{calendarDays.map((day, idx) => { if (!day) return
; const key = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const hasTasks = Boolean(tasksByDate[key]?.length); const hasNotes = Boolean(notesByDate[key]?.length); const taskCount = tasksByDate[key]?.length ?? 0; const noteCount = notesByDate[key]?.length ?? 0; const isToday = today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === day; const isSelected = selectedDay === day; return ( ); })}
{selectedDay ? ( <>

{formatDayLabel(viewYear, viewMonth, selectedDay)}

{selectedDayTasks.length === 0 && selectedDayNotes.length === 0 ? (

Nema dogadjaja za odabrani datum.

) : (
{selectedDayNotes.length > 0 && (

Biljeske

    {selectedDayNotes.map((note) => (
  • {note.note} {note.task_title || note.work_order_label || 'Servisni kontekst'}
  • ))}
)} {selectedDayTasks.length > 0 && (

Zadaci

    {selectedDayTasks.map((task) => (
  • ))}
)}
)} ) : ( <>

Odaberite datum u kalendaru za prikaz zadataka.

{totalScheduled > 0 && (

{totalScheduled} zadatak/a s planiranim datumom.

)} {totalNoteReminders > 0 && (

{totalNoteReminders} biljeski/podsjetnika u kalendaru.

)} {totalScheduled === 0 && (

Nijedan zadatak nema postavljen planirani datum.

)} )}
{reportOpen && (
{reportType === 'servicer' ? 'Mjesecni izvjestaj servisera' : 'Mjesecni izvjestaj troskova'} {MONTH_NAMES[viewMonth]} {viewYear}
{reportType === 'servicer' && ( )}

{reportType === 'servicer' ? 'Tablica prikazuje sve dane u mjesecu. Klikni na red bez putnog naloga i odaberi status dana.' : 'Pregled troskova iz racuna vezanih uz putne naloge u odabranom mjesecu.'}

{reportType === 'servicer' && ( loadingManualEntries ? (

Ucitam dnevne unose...

) : ( {SERVICER_HEADERS.map((header) => ( ))} {servicerRows.map((row, index) => ( handleServicerRowClick(row)} className={[ index % 2 === 0 ? 'bg-canvas-base' : 'bg-canvas-deep', row.clickable ? 'cursor-pointer hover:bg-indigo-50' : '', ].join(' ')} title={row.clickable ? 'Klikni za unos ili izmjenu dana bez putnog naloga.' : undefined} > {row.cells.map((cell, cellIndex) => ( ))} ))}
{header}
{cell}
) )} {reportType === 'costs' && ( loadingCostInvoices ? (

Ucitam troskove...

) : costsRows.length === 0 ? (

Nema troskova za {MONTH_NAMES[viewMonth]} {viewYear}.

) : ( {COST_HEADERS.map((header) => ( ))} {costsRows.map((row, index) => ( {row.map((cell, cellIndex) => ( ))} ))}
{header}
{cell}
) )}
)} {manualEntryTarget && (

{manualEntryDraft ? 'Unos radnog dana' : 'Odaberi vrstu unosa'}

{formatDateFromKey(manualEntryTarget.key)} nema putni nalog. Odaberi status dana.

{!manualEntryDraft ? (
) : (
Redovan rad (h): {calculateHours(manualEntryDraft.startTime, manualEntryDraft.endTime) || '0'}
{manualEntryError && (

{manualEntryError}

)}
)}
)} {bulkDownloadOpen && reportType === 'servicer' && (

Preuzmi ZIP arhivu

{MONTH_NAMES[viewMonth]} {viewYear}

)}
)} ); }