diff --git a/backend/modules/fleet/migrations/0034_monthlyservicerdayentry.py b/backend/modules/fleet/migrations/0034_monthlyservicerdayentry.py new file mode 100644 index 0000000..418513b --- /dev/null +++ b/backend/modules/fleet/migrations/0034_monthlyservicerdayentry.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.15 on 2026-07-31 12:30 + +import uuid +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0033_emaildispatchlog'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MonthlyServicerDayEntry', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('entry_date', models.DateField(db_index=True, verbose_name='Datum')), + ('entry_type', models.CharField(choices=[('office', 'Rad u uredu'), ('sick_leave', 'Bolovanje'), ('vacation', 'Godišnji odmor')], max_length=24, verbose_name='Vrsta unosa')), + ('description', models.CharField(blank=True, max_length=255, verbose_name='Opis posla')), + ('location', models.CharField(blank=True, max_length=255, verbose_name='Mjesto rada')), + ('start_time', models.TimeField(blank=True, null=True, verbose_name='Početak rada')), + ('end_time', models.TimeField(blank=True, null=True, verbose_name='Kraj rada')), + ('regular_hours', models.DecimalField(decimal_places=2, default=Decimal('8.00'), max_digits=5, verbose_name='Redovan rad')), + ('overtime_hours', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=5, verbose_name='Prekovremeni rad')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_servicer_day_entries', to=settings.AUTH_USER_MODEL, verbose_name='Serviser')), + ], + options={ + 'verbose_name': 'Dnevni unos mjesečnog izvještaja servisera', + 'verbose_name_plural': 'Dnevni unosi mjesečnog izvještaja servisera', + 'ordering': ['entry_date'], + }, + ), + migrations.AddConstraint( + model_name='monthlyservicerdayentry', + constraint=models.UniqueConstraint(fields=('user', 'entry_date', 'is_active'), name='uniq_monthly_servicer_day_entry_user_date_active'), + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index 6afeb86..e46d8d7 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -1,5 +1,6 @@ import uuid import re +from datetime import time from django.db import models from django.conf import settings from django.core.exceptions import ValidationError @@ -694,4 +695,84 @@ class ServiceContextNote(BaseModel): if self.task.work_order_id != self.work_order_id: raise ValidationError({ 'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."), - }) \ No newline at end of file + }) + + +class MonthlyServicerDayEntry(BaseModel): + ENTRY_TYPE_CHOICES = [ + ('office', _('Rad u uredu')), + ('sick_leave', _('Bolovanje')), + ('vacation', _('Godišnji odmor')), + ] + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='monthly_servicer_day_entries', + verbose_name=_('Serviser'), + ) + entry_date = models.DateField(db_index=True, verbose_name=_('Datum')) + entry_type = models.CharField( + max_length=24, + choices=ENTRY_TYPE_CHOICES, + verbose_name=_('Vrsta unosa'), + ) + description = models.CharField(max_length=255, blank=True, verbose_name=_('Opis posla')) + location = models.CharField(max_length=255, blank=True, verbose_name=_('Mjesto rada')) + start_time = models.TimeField(null=True, blank=True, verbose_name=_('Početak rada')) + end_time = models.TimeField(null=True, blank=True, verbose_name=_('Kraj rada')) + regular_hours = models.DecimalField( + max_digits=5, + decimal_places=2, + default=Decimal('8.00'), + verbose_name=_('Redovan rad'), + ) + overtime_hours = models.DecimalField( + max_digits=5, + decimal_places=2, + default=Decimal('0.00'), + verbose_name=_('Prekovremeni rad'), + ) + + class Meta: + verbose_name = _('Dnevni unos mjesečnog izvještaja servisera') + verbose_name_plural = _('Dnevni unosi mjesečnog izvještaja servisera') + ordering = ['entry_date'] + constraints = [ + models.UniqueConstraint( + fields=['user', 'entry_date', 'is_active'], + name='uniq_monthly_servicer_day_entry_user_date_active', + ), + ] + + def __str__(self): + return f"{self.user_id} @ {self.entry_date} ({self.entry_type})" + + def apply_defaults(self): + if self.entry_type == 'office': + self.description = 'Rad u uredu' + self.location = 'Zagreb (ured)' + self.start_time = time(8, 0) + self.end_time = time(16, 0) + self.regular_hours = Decimal('8.00') + self.overtime_hours = Decimal('0.00') + return + + if self.entry_type == 'vacation': + self.description = 'Godišnji odmor' + elif self.entry_type == 'sick_leave': + self.description = 'Bolovanje' + + self.location = '' + self.start_time = None + self.end_time = None + self.regular_hours = Decimal('8.00') + self.overtime_hours = Decimal('0.00') + + def clean(self): + super().clean() + self.apply_defaults() + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) \ No newline at end of file diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py index 74fc6c4..b8297b0 100644 --- a/backend/modules/fleet/serializers.py +++ b/backend/modules/fleet/serializers.py @@ -10,6 +10,7 @@ from .models import ( Crane, Vehicle, ServiceContextNote, + MonthlyServicerDayEntry, VehicleNotification, WorkOrder, WorkOrderAdditionalCostsTable, @@ -228,6 +229,35 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer): attrs['target_users'] = [target_user] return attrs + +class MonthlyServicerDayEntrySerializer(serializers.ModelSerializer): + class Meta: + model = MonthlyServicerDayEntry + fields = [ + 'id', + 'entry_date', + 'entry_type', + 'description', + 'location', + 'start_time', + 'end_time', + 'regular_hours', + 'overtime_hours', + 'created_at', + 'updated_at', + ] + read_only_fields = [ + 'id', + 'description', + 'location', + 'start_time', + 'end_time', + 'regular_hours', + 'overtime_hours', + 'created_at', + 'updated_at', + ] + class WorkOrderSerializer(serializers.ModelSerializer): crane = serializers.SerializerMethodField() crane_label = serializers.SerializerMethodField() diff --git a/backend/modules/fleet/urls.py b/backend/modules/fleet/urls.py index a3b7388..50671e2 100644 --- a/backend/modules/fleet/urls.py +++ b/backend/modules/fleet/urls.py @@ -3,7 +3,7 @@ from django.urls import path from .views import ( CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, - monthly_servicer_report_docx, monthly_costs_report_docx, + monthly_servicer_report_docx, monthly_costs_report_docx, MonthlyServicerDayEntryViewSet, ) router = DefaultRouter() @@ -16,6 +16,7 @@ router.register(r'service-records', VehicleServiceRecordViewSet, basename='servi router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification') router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo') router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment') +router.register(r'monthly-servicer-day-entries', MonthlyServicerDayEntryViewSet, basename='monthly-servicer-day-entry') urlpatterns = router.urls diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 593e612..63bebf1 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -45,6 +45,7 @@ from .models import ( VehicleNotification, EmailDispatchLog, ServiceContextNote, + MonthlyServicerDayEntry, WorkOrder, WorkOrderPhoto, GeneratedWorkOrderPdf, @@ -65,6 +66,7 @@ from .serializers import ( VehicleNotificationSerializer, ServiceContextNoteSerializer, ServiceContextNoteCreateSerializer, + MonthlyServicerDayEntrySerializer, VehicleServicePhotoSerializer, VehicleServiceAttachmentSerializer, ) @@ -2114,6 +2116,210 @@ _MONTH_NAMES_HR = [ ] +def _format_report_hours(value, default='-'): + if value in (None, ''): + return default + try: + normalized = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return str(value) + text = format(normalized.normalize(), 'f') + if '.' in text: + text = text.rstrip('0').rstrip('.') + return text or '0' + + +def _build_monthly_servicer_report_rows(user, year, month): + from calendar import monthrange + from datetime import date as _date + from modules.task_management.models import Task as _TaskModel + + tasks_qs = ( + _TaskModel.objects + .filter( + assigned_to=user, + is_active=True, + scheduled_date__year=year, + scheduled_date__month=month, + ) + .select_related( + 'vehicle', + 'vehicle__client', + 'work_order', + 'work_order__vehicle', + 'work_order__vehicle__client', + ) + .order_by('scheduled_date', 'created_at') + ) + manual_entries = MonthlyServicerDayEntry.objects.filter( + user=user, + is_active=True, + entry_date__year=year, + entry_date__month=month, + ).order_by('entry_date') + + tasks_by_date = OrderedDict() + for task in tasks_qs: + tasks_by_date.setdefault(task.scheduled_date, []).append(task) + + manual_by_date = { + entry.entry_date: entry + for entry in manual_entries + } + + rows = [] + days_in_month = monthrange(year, month)[1] + for day in range(1, days_in_month + 1): + current_date = _date(year, month, day) + day_tasks = tasks_by_date.get(current_date, []) + if day_tasks: + titles = OrderedDict() + serials = OrderedDict() + clients = OrderedDict() + locations = OrderedDict() + work_orders = OrderedDict() + start_values = [] + end_values = [] + counted_work_order_ids = set() + total_hours = 0.0 + + for task in day_tasks: + title = str(task.title or '').strip() + if title: + titles[title] = title + + vehicle = getattr(task, 'vehicle', None) + work_order = getattr(task, 'work_order', None) + if work_order and getattr(work_order, 'vehicle', None): + vehicle = work_order.vehicle + + serial_value = str(getattr(vehicle, 'crane_serial_number', '') or '').strip() + if serial_value: + serials[serial_value] = serial_value + + client_name = str(getattr(getattr(vehicle, 'client', None), 'name', '') or '').strip() + if client_name: + clients[client_name] = client_name + + location = str(getattr(work_order, 'location', '') or '').strip() + if location: + locations[location] = location + + display_code = str(getattr(work_order, 'display_code', '') or '').strip() + if display_code: + work_orders[display_code] = display_code + + if work_order and work_order.travel_start_at and work_order.travel_end_at and work_order.travel_end_at > work_order.travel_start_at and str(work_order.id) not in counted_work_order_ids: + counted_work_order_ids.add(str(work_order.id)) + start_values.append((work_order.id, timezone.localtime(work_order.travel_start_at))) + end_values.append((work_order.id, timezone.localtime(work_order.travel_end_at))) + total_hours += (work_order.travel_end_at - work_order.travel_start_at).total_seconds() / 3600.0 + + start_label = '-' + end_label = '-' + if start_values: + start_label = min(value for _, value in start_values).strftime('%H:%M') + if end_values: + end_label = max(value for _, value in end_values).strftime('%H:%M') + + regular_hours = '-' + overtime_hours = '0' + if total_hours > 0: + regular_hours = _format_report_hours(min(total_hours, 8.0), default='0') + overtime_hours = _format_report_hours(max(total_hours - 8.0, 0.0), default='0') + + rows.append({ + 'date': current_date, + 'datum': current_date.strftime('%d.%m.%Y'), + 'opis_posla': ' | '.join(titles.values()) or '-', + 'br_dizalice': ', '.join(serials.values()) or '-', + 'komitent': ', '.join(clients.values()) or '-', + 'mjesto_rada': ', '.join(locations.values()) or '-', + 'pocetak_rada': start_label, + 'kraj_rada': end_label, + 'redovan_rad': regular_hours, + 'prekovremeni': overtime_hours, + 'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-', + 'source': 'task', + }) + continue + + manual_entry = manual_by_date.get(current_date) + if manual_entry: + rows.append({ + 'date': current_date, + 'datum': current_date.strftime('%d.%m.%Y'), + 'opis_posla': str(manual_entry.description or '').strip() or '-', + 'br_dizalice': '-', + 'komitent': '-', + 'mjesto_rada': str(manual_entry.location or '').strip() or '-', + 'pocetak_rada': manual_entry.start_time.strftime('%H:%M') if manual_entry.start_time else '-', + 'kraj_rada': manual_entry.end_time.strftime('%H:%M') if manual_entry.end_time else '-', + 'redovan_rad': _format_report_hours(manual_entry.regular_hours, default='0'), + 'prekovremeni': _format_report_hours(manual_entry.overtime_hours, default='0'), + 'radni_nalog': '-', + 'source': manual_entry.entry_type, + }) + continue + + rows.append({ + 'date': current_date, + 'datum': current_date.strftime('%d.%m.%Y'), + 'opis_posla': '-', + 'br_dizalice': '-', + 'komitent': '-', + 'mjesto_rada': '-', + 'pocetak_rada': '-', + 'kraj_rada': '-', + 'redovan_rad': '-', + 'prekovremeni': '0', + 'radni_nalog': '-', + 'source': 'empty', + }) + + return rows + + +class MonthlyServicerDayEntryViewSet(viewsets.ModelViewSet): + serializer_class = MonthlyServicerDayEntrySerializer + permission_classes = [permissions.IsAuthenticated] + http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + + def get_queryset(self): + queryset = MonthlyServicerDayEntry.objects.filter( + user=self.request.user, + is_active=True, + ).order_by('entry_date') + year = self.request.query_params.get('year') + month = self.request.query_params.get('month') + if year and str(year).isdigit(): + queryset = queryset.filter(entry_date__year=int(year)) + if month and str(month).isdigit(): + queryset = queryset.filter(entry_date__month=int(month)) + return queryset + + def create(self, request, *args, **kwargs): + raw_entry_date = request.data.get('entry_date') + existing = None + if raw_entry_date: + existing = self.get_queryset().filter(entry_date=raw_entry_date).first() + + if existing is not None: + serializer = self.get_serializer(existing, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + entry = serializer.save() + return Response(self.get_serializer(entry).data, status=status.HTTP_200_OK) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + entry = serializer.save(user=request.user) + return Response(self.get_serializer(entry).data, status=status.HTTP_201_CREATED) + + def perform_destroy(self, instance): + instance.is_active = False + instance.save(update_fields=['is_active', 'updated_at']) + + @api_view(['GET']) @permission_classes([permissions.IsAuthenticated]) def monthly_servicer_report_docx(request): @@ -2128,61 +2334,10 @@ def monthly_servicer_report_docx(request): return Response({'detail': 'Nevažeći year/month parametar.'}, status=400) try: - from modules.task_management.models import Task as _TaskModel + normalized_rows = _build_monthly_servicer_report_rows(request.user, year, month) except ImportError: return Response({'detail': 'Task model nije dostupan.'}, status=500) - tasks_qs = ( - _TaskModel.objects - .filter( - assigned_to=request.user, - is_active=True, - scheduled_date__year=year, - scheduled_date__month=month, - ) - .select_related('work_order', 'work_order__vehicle', 'work_order__vehicle__client') - .order_by('scheduled_date', 'created_at') - ) - - rows = [] - for task in tasks_qs: - wo = task.work_order - datum = task.scheduled_date.strftime('%d.%m.%Y') if task.scheduled_date else '-' - opis_posla = task.title or '-' - br_dizalice = '-' - komitent = '-' - mjesto_rada = '-' - pocetak_rada = '-' - kraj_rada = '-' - redovan_rad = '-' - prekovremeni = '0' - radni_nalog = '-' - - if wo: - vehicle = getattr(wo, 'vehicle', None) - if vehicle: - sn = str(getattr(vehicle, 'crane_serial_number', '') or '').strip() - br_dizalice = sn or '-' - client_obj = getattr(vehicle, 'client', None) - komitent = str(getattr(client_obj, 'name', '') or '').strip() or '-' - mjesto_rada = wo.location or '-' - ts = wo.travel_start_at - te = wo.travel_end_at - if ts: - pocetak_rada = timezone.localtime(ts).strftime('%H:%M') - if te: - kraj_rada = timezone.localtime(te).strftime('%H:%M') - if ts and te and te > ts: - delta_h = (te - ts).total_seconds() / 3600.0 - reg = min(delta_h, 8.0) - ovt = max(0.0, delta_h - 8.0) - redovan_rad = f"{reg:.2f}".rstrip('0').rstrip('.') - prekovremeni = f"{ovt:.2f}".rstrip('0').rstrip('.') if ovt > 0 else '0' - radni_nalog = str(wo.display_code or '').strip() or '-' - - rows.append([datum, opis_posla, br_dizalice, komitent, mjesto_rada, - pocetak_rada, kraj_rada, redovan_rad, prekovremeni, radni_nalog]) - from docx import Document as _DocxDoc from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH @@ -2217,7 +2372,7 @@ def monthly_servicer_report_docx(request): col_widths = [Cm(2.2), Cm(5.5), Cm(2.5), Cm(3.5), Cm(3.5), Cm(2.2), Cm(2.2), Cm(2.4), Cm(2.4), Cm(2.4)] - table = doc.add_table(rows=1 + len(rows), cols=len(headers)) + table = doc.add_table(rows=1 + len(normalized_rows), cols=len(headers)) table.style = 'Table Grid' hdr_cells = table.rows[0].cells @@ -2229,9 +2384,21 @@ def monthly_servicer_report_docx(request): run_h.bold = True run_h.font.size = Pt(8) - for ri, row_data in enumerate(rows): + for ri, row_data in enumerate(normalized_rows): data_cells = table.rows[ri + 1].cells - for ci, (val, w) in enumerate(zip(row_data, col_widths)): + values = [ + row_data['datum'], + row_data['opis_posla'], + row_data['br_dizalice'], + row_data['komitent'], + row_data['mjesto_rada'], + row_data['pocetak_rada'], + row_data['kraj_rada'], + row_data['redovan_rad'], + row_data['prekovremeni'], + row_data['radni_nalog'], + ] + for ci, (val, w) in enumerate(zip(values, col_widths)): data_cells[ci].width = w data_cells[ci].text = str(val) if data_cells[ci].paragraphs[0].runs: diff --git a/frontend/src/components/dashboard/TaskCalendarWidget.jsx b/frontend/src/components/dashboard/TaskCalendarWidget.jsx index 599e5d1..729a1f2 100644 --- a/frontend/src/components/dashboard/TaskCalendarWidget.jsx +++ b/frontend/src/components/dashboard/TaskCalendarWidget.jsx @@ -1,48 +1,81 @@ -import { useMemo, useState } from 'preact/hooks'; +import { useEffect, useMemo, useState } from 'preact/hooks'; import { getStatusLabel } from '../../stores/taskStore'; -import { downloadMonthlyServiserReport, downloadMonthlyCostsReport } from '../../stores/fleetDashboardStore'; +import { + downloadMonthlyCostsReport, + downloadMonthlyServiserReport, + fetchMonthlyServicerEntries, + upsertMonthlyServicerEntry, +} from '../../stores/fleetDashboardStore'; import LeftSlideDrawer from '../layout/LeftSlideDrawer'; import { getTaskCraneKind, getTaskCraneOwner, getTaskCraneSerial } from '../../lib/taskCraneDisplay'; -const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'ÄŚe', 'Pe', 'Su', 'Ne']; +const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Ce', 'Pe', 'Su', 'Ne']; const MONTH_NAMES = [ - 'SijeÄŤanj', 'VeljaÄŤa', 'OĹľujak', 'Travanj', 'Svibanj', 'Lipanj', + 'Sijecanj', 'Veljaca', 'Ozujak', 'Travanj', 'Svibanj', 'Lipanj', 'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac', ]; -function toIsoDateKey(dateStr) { - if (!dateStr) return null; - const d = new Date(dateStr); - if (Number.isNaN(d.getTime())) return null; - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +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 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 formatTime(isoStr) { - if (!isoStr) return '-'; - const d = new Date(isoStr); - if (Number.isNaN(d.getTime())) return '-'; - return d.toLocaleTimeString('hr-HR', { hour: '2-digit', minute: '2-digit' }); +function formatDateFromKey(dateKey) { + const [year, month, day] = String(dateKey || '').split('-'); + if (!year || !month || !day) return '-'; + return `${day}.${month}.${year}.`; } -const SERVICER_HEADERS = ['DATUM', 'OPIS POSLA', 'BR. DIZALICE', 'KOMITENT', 'MJESTO RADA', 'POÄŚETAK', 'KRAJ', 'REDOVAN (h)', 'PREKOVR. (h)', 'RADNI NALOG']; +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 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(', '); +} 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); // 'servicer' | 'costs' | null + const [reportType, setReportType] = useState(null); const [downloading, setDownloading] = useState(false); + const [manualEntries, setManualEntries] = useState([]); + const [loadingManualEntries, setLoadingManualEntries] = useState(false); + const [manualEntryTarget, setManualEntryTarget] = useState(null); + const [savingManualEntry, setSavingManualEntry] = useState(false); const reportOpen = reportType !== null; - const panelWidth = reportOpen ? 680 : 300; + const panelWidth = reportOpen ? 920 : 300; const tasksByDate = useMemo(() => { const map = {}; for (const task of tasks) { - const key = toIsoDateKey(task.scheduled_date); + const key = toDateKey(task.scheduled_date); if (!key) continue; if (!map[key]) map[key] = []; map[key].push(task); @@ -53,7 +86,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders const notesByDate = useMemo(() => { const map = {}; for (const note of notes) { - const key = toIsoDateKey(note.note_date); + const key = toDateKey(note.note_date); if (!key) continue; if (!map[key]) map[key] = []; map[key].push(note); @@ -61,84 +94,182 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders 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++) cells.push(null); - for (let d = 1; d <= daysInMonth; d++) cells.push(d); + 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]); - // Rows for servicer report preview table (current month tasks) + 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]); + const servicerRows = useMemo(() => { if (reportType !== 'servicer') return []; - return tasks - .filter((t) => { - const d = t.scheduled_date ? new Date(t.scheduled_date) : null; - return d && !Number.isNaN(d.getTime()) && d.getFullYear() === viewYear && d.getMonth() === viewMonth; - }) - .sort((a, b) => (a.scheduled_date || '').localeCompare(b.scheduled_date || '')) - .map((task) => { - const wo = workOrders.find((w) => String(w.id) === String(task.work_order)); - let start = '-', end = '-', regular = '-', overtime = '0'; - if (wo?.travel_start_at) start = formatTime(wo.travel_start_at); - if (wo?.travel_end_at) end = formatTime(wo.travel_end_at); - if (wo?.travel_start_at && wo?.travel_end_at) { - const deltaH = (new Date(wo.travel_end_at) - new Date(wo.travel_start_at)) / 3_600_000; - if (deltaH > 0) { - const reg = Math.min(deltaH, 8); - const ovt = Math.max(0, deltaH - 8); - regular = reg % 1 === 0 ? String(reg) : reg.toFixed(2); - overtime = ovt > 0 ? (ovt % 1 === 0 ? String(ovt) : ovt.toFixed(2)) : '0'; + 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 dayTasks = tasksByDate[key] ?? []; + const manualEntry = manualEntriesByDate[key]; + + if (dayTasks.length > 0) { + const titles = []; + const serials = []; + const clients = []; + const locations = []; + const workOrderLabels = []; + const workStartTimes = []; + const workEndTimes = []; + const countedWorkOrders = new Set(); + let totalHours = 0; + + for (const task of dayTasks) { + if (task?.title) titles.push(task.title); + serials.push(getTaskCraneSerial(task)); + clients.push(getTaskCraneOwner(task)); + if (task?.work_order_label) workOrderLabels.push(task.work_order_label); + + const workOrder = workOrdersById.get(String(task.work_order || '')); + if (workOrder?.location) locations.push(workOrder.location); + if ( + workOrder?.travel_start_at + && workOrder?.travel_end_at + && !countedWorkOrders.has(String(workOrder.id)) + ) { + const startAt = new Date(workOrder.travel_start_at); + const endAt = new Date(workOrder.travel_end_at); + if (!Number.isNaN(startAt.getTime()) && !Number.isNaN(endAt.getTime()) && endAt > startAt) { + countedWorkOrders.add(String(workOrder.id)); + workStartTimes.push(startAt); + workEndTimes.push(endAt); + totalHours += (endAt.getTime() - startAt.getTime()) / 3600000; + } } } - const datum = task.scheduled_date - ? new Date(task.scheduled_date).toLocaleDateString('hr-HR') - : '-'; - return [ - datum, - task.title || '-', - getTaskCraneSerial(task), - getTaskCraneOwner(task), - wo?.location || '-', - start, - end, - regular, - overtime, - task.work_order_label || '-', - ]; - }); - }, [reportType, tasks, workOrders, viewYear, viewMonth]); - // Costs report rows: aggregate invoices from work orders linked to tasks in current month + rows.push({ + key, + clickable: false, + source: 'task', + cells: [ + formatDateFromKey(key), + joinUnique(titles) || '-', + joinUnique(serials) || '-', + joinUnique(clients) || '-', + joinUnique(locations) || '-', + workStartTimes.length ? formatTime(new Date(Math.min(...workStartTimes.map((item) => item.getTime()))).toISOString()) : '-', + workEndTimes.length ? formatTime(new Date(Math.max(...workEndTimes.map((item) => item.getTime()))).toISOString()) : '-', + totalHours > 0 ? formatHourValue(Math.min(totalHours, 8), '0') : '-', + totalHours > 8 ? formatHourValue(totalHours - 8, '0') : '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, reportType, tasksByDate, viewMonth, viewYear, workOrdersById]); + const costsRows = useMemo(() => { if (reportType !== 'costs') return []; - // Collect work order IDs from tasks in current month - const woIds = new Set( + const monthPrefix = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`; + const workOrderIds = new Set( tasks - .filter((t) => { - const d = t.scheduled_date ? new Date(t.scheduled_date) : null; - return d && !Number.isNaN(d.getTime()) && d.getFullYear() === viewYear && d.getMonth() === viewMonth && t.work_order; + .filter((task) => { + const key = toDateKey(task.scheduled_date); + return Boolean(key && key.startsWith(monthPrefix) && task.work_order); }) - .map((t) => String(t.work_order)) + .map((task) => String(task.work_order)) ); - // Filter work orders with invoices + return workOrders - .filter((wo) => woIds.has(String(wo.id)) && Array.isArray(wo.invoices) && wo.invoices.length > 0) - .flatMap((wo) => - wo.invoices.map((inv) => [ - inv.datum ? new Date(inv.datum).toLocaleDateString('hr-HR') : '-', - inv.naziv_racuna || '-', - inv.lokacija || '-', - inv.opis || '-', - wo.display_code || '-', - ]) - ) - .sort((a, b) => (a[0] || '').localeCompare(b[0] || '')); - }, [reportType, tasks, workOrders, viewYear, viewMonth]); + .filter((workOrder) => workOrderIds.has(String(workOrder.id)) && Array.isArray(workOrder.invoices)) + .flatMap((workOrder) => ( + workOrder.invoices.map((invoice) => ([ + invoice?.datum ? new Date(invoice.datum).toLocaleDateString('hr-HR') : '-', + invoice?.naziv_racuna || '-', + invoice?.lokacija || '-', + invoice?.opis || '-', + workOrder.display_code || '-', + ])) + )); + }, [reportType, tasks, viewYear, viewMonth, workOrders]); const selectedDayKey = selectedDay ? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}` @@ -146,16 +277,27 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders 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); } - else setViewMonth((m) => m - 1); + 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); } - else setViewMonth((m) => m + 1); + if (viewMonth === 11) { + setViewYear((y) => y + 1); + setViewMonth(0); + return; + } + setViewMonth((m) => m + 1); } function handleDayClick(day) { @@ -169,6 +311,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders function toggleReport(type) { setReportType((prev) => (prev === type ? null : type)); + setManualEntryTarget(null); } async function handleDownload() { @@ -177,7 +320,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders try { if (reportType === 'servicer') { await downloadMonthlyServiserReport(viewYear, viewMonth + 1); - } else { + } else if (reportType === 'costs') { await downloadMonthlyCostsReport(viewYear, viewMonth + 1); } } finally { @@ -185,14 +328,40 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders } } - const totalScheduled = Object.values(tasksByDate).flat().length; - const totalNoteReminders = Object.values(notesByDate).flat().length; + function handleServicerRowClick(row) { + if (!row?.clickable) return; + setManualEntryTarget(row); + } + + async function handleSaveManualEntry(entryType) { + if (!manualEntryTarget || savingManualEntry) return; + setSavingManualEntry(true); + try { + const saved = await upsertMonthlyServicerEntry({ + entry_date: manualEntryTarget.key, + entry_type: entryType, + }); + 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); + } finally { + setSavingManualEntry(false); + } + } return ( {({ close }) => ( -
- - {/* ── Calendar column (fixed 300 px) ── */} +
- {/* Report toggle buttons */}
- {/* Month navigation */}
{MONTH_NAMES[viewMonth]} {viewYear} @@ -255,13 +420,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders type="button" onClick={nextMonth} className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep" - aria-label="Sljedeći mjesec" + aria-label="Sljedeci mjesec" > - ▶ + >
- {/* Calendar grid */}
{WEEKDAY_LABELS.map((label) => ( @@ -279,16 +443,16 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders const taskCount = tasksByDate[key]?.length ?? 0; const noteCount = notesByDate[key]?.length ?? 0; const isToday = - today.getFullYear() === viewYear && - today.getMonth() === viewMonth && - today.getDate() === day; + today.getFullYear() === viewYear + && today.getMonth() === viewMonth + && today.getDate() === day; const isSelected = selectedDay === day; return (
- {/* Divider */}
- {/* Task list for selected day OR hint */}
{selectedDay ? ( <> @@ -328,12 +490,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders {formatDayLabel(viewYear, viewMonth, selectedDay)}

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

Nema događaja za odabrani datum.

+

Nema dogadjaja za odabrani datum.

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

Bilješke

+

Biljeske

    {selectedDayNotes.map((note) => (
  • @@ -355,14 +517,16 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
  • @@ -385,7 +549,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders )} {totalNoteReminders > 0 && (

    - {totalNoteReminders} bilješki/podsjetnika u kalendaru. + {totalNoteReminders} biljeski/podsjetnika u kalendaru.

    )} {totalScheduled === 0 && ( @@ -398,14 +562,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
- {/* ── Report panel (visible when reportType is set) ── */} {reportOpen && (
- {/* Report header */}
- {reportType === 'servicer' ? '📊 Izvještaj servisera' : '💰 Izvještaj troškova'} + {reportType === 'servicer' ? 'Mjesecni izvjestaj servisera' : 'Mjesecni izvjestaj troskova'} {MONTH_NAMES[viewMonth]} {viewYear} @@ -413,45 +575,48 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
- {/* Report info note */}

{reportType === 'servicer' - ? 'Pregled podataka za odabrani mjesec. Preuzmi DOCX za točan i formatiran izvještaj.' - : 'Pregled troškova za odabrani mjesec iz putnih naloga. Preuzmi DOCX za točan izvještaj.' - } + ? '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.'}

- {/* Table */}
{reportType === 'servicer' && ( - servicerRows.length === 0 ? ( -

- Nema zadataka za {MONTH_NAMES[viewMonth]} {viewYear}. -

+ loadingManualEntries ? ( +

Ucitam dnevne unose...

) : ( - +
- {SERVICER_HEADERS.map((h) => ( - ))} - {servicerRows.map((row, i) => ( - - {row.map((cell, j) => ( - 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) => ( + ))} @@ -461,29 +626,28 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
- {h} + {SERVICER_HEADERS.map((header) => ( + + {header}
+ {servicerRows.map((row, index) => ( +
{cell}
) )} + {reportType === 'costs' && ( costsRows.length === 0 ? (

- Nema troškova za {MONTH_NAMES[viewMonth]} {viewYear}. -
- Napomena: prikaz ovisi o dostupnim podacima raÄŤuna u putnim nalozima. + Nema troskova za {MONTH_NAMES[viewMonth]} {viewYear}.

) : ( - +
- {['DATUM', 'NAZIV RAÄŚUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG'].map((h) => ( - ))} - {costsRows.map((row, i) => ( - - {row.map((cell, j) => ( - + {row.map((cell, cellIndex) => ( + ))} @@ -496,6 +660,59 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders )} + + {manualEntryTarget && ( +
+
+
+
+

Odaberi vrstu unosa

+

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

+
+ +
+ +
+ + + +
+
+
+ )} )} diff --git a/frontend/src/components/layout/TaskCalendarPortal.jsx b/frontend/src/components/layout/TaskCalendarPortal.jsx index 05dfc8d..0616527 100644 --- a/frontend/src/components/layout/TaskCalendarPortal.jsx +++ b/frontend/src/components/layout/TaskCalendarPortal.jsx @@ -64,6 +64,17 @@ export default function TaskCalendarPortal() { )); }, [workOrders, isSupervisorNotServiser, user?.id]); + const calendarWorkOrders = useMemo(() => { + const base = Array.isArray(workOrders) ? workOrders : []; + if (isSupervisorNotServiser) { + return base; + } + return base.filter((item) => ( + String(item.creator || '') === String(user?.id || '') + || String(item.craneInfo?.assigned_servicer || '') === String(user?.id || '') + )); + }, [workOrders, isSupervisorNotServiser, user?.id]); + useEffect(() => { if (!user?.id) return; fetchServiceNotes().catch(() => {}); @@ -103,7 +114,7 @@ export default function TaskCalendarPortal() {
- {h} + {COST_HEADERS.map((header) => ( + + {header}
+ {costsRows.map((row, index) => ( +
{cell}