feat: dnevni unosi i svi dani u mjesecnom izvjestaju servisera
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

- Dodan model MonthlyServicerDayEntry s automatskom popunom prema tipu:
  office (08:00-16:00, Zagreb ured), vacation i sick_leave (8h bez vremena)
- Migracija 0034_monthlyservicerdayentry za novu tablicu s UniqueConstraint
- Novi ViewSet + serializer + endpoint fleet/monthly-servicer-day-entries/
  (upsert: POST za novi dan ili update za isti dan ako vec postoji)
- Backend DOCX report (_build_monthly_servicer_report_rows) sad generira
  redak za SVAKI dan u mjesecu: taskovi > rucni unos > prazan red
- TaskCalendarWidget potpuno prepisan bez mojibake znakova
  (zamjena UTF-8 specijalnih znakova ASCII ekvivalentima u string literalima)
- Tablica izvjestaja servisera: klikom na red bez naloga otvara se overlay
  s tri opcije (Rad u uredu / Bolovanje / Godisnji)
- TaskCalendarPortal: dodan calendarWorkOrders computed (svi statusi)
  kako bi report imao potpune podatke o radnom vremenu
- fetchMonthlyServicerEntries i upsertMonthlyServicerEntry u store-u

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-31 16:42:07 +02:00
parent fe739b14c0
commit 3a33751012
8 changed files with 781 additions and 205 deletions

View File

@@ -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'),
),
]

View File

@@ -1,5 +1,6 @@
import uuid import uuid
import re import re
from datetime import time
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -694,4 +695,84 @@ class ServiceContextNote(BaseModel):
if self.task.work_order_id != self.work_order_id: if self.task.work_order_id != self.work_order_id:
raise ValidationError({ raise ValidationError({
'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."), 'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."),
}) })
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)

View File

@@ -10,6 +10,7 @@ from .models import (
Crane, Crane,
Vehicle, Vehicle,
ServiceContextNote, ServiceContextNote,
MonthlyServicerDayEntry,
VehicleNotification, VehicleNotification,
WorkOrder, WorkOrder,
WorkOrderAdditionalCostsTable, WorkOrderAdditionalCostsTable,
@@ -228,6 +229,35 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
attrs['target_users'] = [target_user] attrs['target_users'] = [target_user]
return attrs 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): class WorkOrderSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField() crane = serializers.SerializerMethodField()
crane_label = serializers.SerializerMethodField() crane_label = serializers.SerializerMethodField()

View File

@@ -3,7 +3,7 @@ from django.urls import path
from .views import ( from .views import (
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, 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() 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'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo') router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')
router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment') 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 urlpatterns = router.urls

View File

@@ -45,6 +45,7 @@ from .models import (
VehicleNotification, VehicleNotification,
EmailDispatchLog, EmailDispatchLog,
ServiceContextNote, ServiceContextNote,
MonthlyServicerDayEntry,
WorkOrder, WorkOrder,
WorkOrderPhoto, WorkOrderPhoto,
GeneratedWorkOrderPdf, GeneratedWorkOrderPdf,
@@ -65,6 +66,7 @@ from .serializers import (
VehicleNotificationSerializer, VehicleNotificationSerializer,
ServiceContextNoteSerializer, ServiceContextNoteSerializer,
ServiceContextNoteCreateSerializer, ServiceContextNoteCreateSerializer,
MonthlyServicerDayEntrySerializer,
VehicleServicePhotoSerializer, VehicleServicePhotoSerializer,
VehicleServiceAttachmentSerializer, 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']) @api_view(['GET'])
@permission_classes([permissions.IsAuthenticated]) @permission_classes([permissions.IsAuthenticated])
def monthly_servicer_report_docx(request): 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) return Response({'detail': 'Nevažeći year/month parametar.'}, status=400)
try: try:
from modules.task_management.models import Task as _TaskModel normalized_rows = _build_monthly_servicer_report_rows(request.user, year, month)
except ImportError: except ImportError:
return Response({'detail': 'Task model nije dostupan.'}, status=500) 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 import Document as _DocxDoc
from docx.shared import Pt, Cm from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH 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), 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)] 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' table.style = 'Table Grid'
hdr_cells = table.rows[0].cells hdr_cells = table.rows[0].cells
@@ -2229,9 +2384,21 @@ def monthly_servicer_report_docx(request):
run_h.bold = True run_h.bold = True
run_h.font.size = Pt(8) 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 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].width = w
data_cells[ci].text = str(val) data_cells[ci].text = str(val)
if data_cells[ci].paragraphs[0].runs: if data_cells[ci].paragraphs[0].runs:

View File

@@ -1,48 +1,81 @@
import { useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { getStatusLabel } from '../../stores/taskStore'; 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 LeftSlideDrawer from '../layout/LeftSlideDrawer';
import { getTaskCraneKind, getTaskCraneOwner, getTaskCraneSerial } from '../../lib/taskCraneDisplay'; 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 = [ const MONTH_NAMES = [
'SijeÄŤanj', 'VeljaÄŤa', 'OĹľujak', 'Travanj', 'Svibanj', 'Lipanj', 'Sijecanj', 'Veljaca', 'Ozujak', 'Travanj', 'Svibanj', 'Lipanj',
'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac', 'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac',
]; ];
function toIsoDateKey(dateStr) { const SERVICER_HEADERS = ['DATUM', 'OPIS POSLA', 'BR. DIZALICE', 'KOMITENT', 'MJESTO RADA', 'POCETAK RADA', 'KRAJ RADA', 'REDOVAN RAD (h)', 'PREKOVREMENI (h)', 'RADNI NALOG'];
if (!dateStr) return null; const COST_HEADERS = ['DATUM', 'NAZIV RACUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG'];
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return null; function toDateKey(value) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 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) { function formatDayLabel(year, month, day) {
return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`; return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`;
} }
function formatTime(isoStr) { function formatDateFromKey(dateKey) {
if (!isoStr) return '-'; const [year, month, day] = String(dateKey || '').split('-');
const d = new Date(isoStr); if (!year || !month || !day) return '-';
if (Number.isNaN(d.getTime())) return '-'; return `${day}.${month}.${year}.`;
return d.toLocaleTimeString('hr-HR', { hour: '2-digit', minute: '2-digit' });
} }
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 }) { export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders = [], onTaskClick }) {
const [viewYear, setViewYear] = useState(() => new Date().getFullYear()); const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth()); const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
const [selectedDay, setSelectedDay] = useState(null); const [selectedDay, setSelectedDay] = useState(null);
const [reportType, setReportType] = useState(null); // 'servicer' | 'costs' | null const [reportType, setReportType] = useState(null);
const [downloading, setDownloading] = useState(false); 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 reportOpen = reportType !== null;
const panelWidth = reportOpen ? 680 : 300; const panelWidth = reportOpen ? 920 : 300;
const tasksByDate = useMemo(() => { const tasksByDate = useMemo(() => {
const map = {}; const map = {};
for (const task of tasks) { for (const task of tasks) {
const key = toIsoDateKey(task.scheduled_date); const key = toDateKey(task.scheduled_date);
if (!key) continue; if (!key) continue;
if (!map[key]) map[key] = []; if (!map[key]) map[key] = [];
map[key].push(task); map[key].push(task);
@@ -53,7 +86,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
const notesByDate = useMemo(() => { const notesByDate = useMemo(() => {
const map = {}; const map = {};
for (const note of notes) { for (const note of notes) {
const key = toIsoDateKey(note.note_date); const key = toDateKey(note.note_date);
if (!key) continue; if (!key) continue;
if (!map[key]) map[key] = []; if (!map[key]) map[key] = [];
map[key].push(note); map[key].push(note);
@@ -61,84 +94,182 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
return map; return map;
}, [notes]); }, [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 calendarDays = useMemo(() => {
const firstDay = new Date(viewYear, viewMonth, 1); const firstDay = new Date(viewYear, viewMonth, 1);
let startDow = firstDay.getDay(); let startDow = firstDay.getDay();
startDow = (startDow + 6) % 7; startDow = (startDow + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells = []; const cells = [];
for (let i = 0; i < startDow; i++) cells.push(null); for (let i = 0; i < startDow; i += 1) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d); for (let d = 1; d <= daysInMonth; d += 1) cells.push(d);
return cells; return cells;
}, [viewYear, viewMonth]); }, [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(() => { const servicerRows = useMemo(() => {
if (reportType !== 'servicer') return []; if (reportType !== 'servicer') return [];
return tasks const rows = [];
.filter((t) => { const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const d = t.scheduled_date ? new Date(t.scheduled_date) : null; const monthPrefix = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
return d && !Number.isNaN(d.getTime()) && d.getFullYear() === viewYear && d.getMonth() === viewMonth;
}) for (let day = 1; day <= daysInMonth; day += 1) {
.sort((a, b) => (a.scheduled_date || '').localeCompare(b.scheduled_date || '')) const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
.map((task) => { const dayTasks = tasksByDate[key] ?? [];
const wo = workOrders.find((w) => String(w.id) === String(task.work_order)); const manualEntry = manualEntriesByDate[key];
let start = '-', end = '-', regular = '-', overtime = '0';
if (wo?.travel_start_at) start = formatTime(wo.travel_start_at); if (dayTasks.length > 0) {
if (wo?.travel_end_at) end = formatTime(wo.travel_end_at); const titles = [];
if (wo?.travel_start_at && wo?.travel_end_at) { const serials = [];
const deltaH = (new Date(wo.travel_end_at) - new Date(wo.travel_start_at)) / 3_600_000; const clients = [];
if (deltaH > 0) { const locations = [];
const reg = Math.min(deltaH, 8); const workOrderLabels = [];
const ovt = Math.max(0, deltaH - 8); const workStartTimes = [];
regular = reg % 1 === 0 ? String(reg) : reg.toFixed(2); const workEndTimes = [];
overtime = ovt > 0 ? (ovt % 1 === 0 ? String(ovt) : ovt.toFixed(2)) : '0'; 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(() => { const costsRows = useMemo(() => {
if (reportType !== 'costs') return []; if (reportType !== 'costs') return [];
// Collect work order IDs from tasks in current month const monthPrefix = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
const woIds = new Set( const workOrderIds = new Set(
tasks tasks
.filter((t) => { .filter((task) => {
const d = t.scheduled_date ? new Date(t.scheduled_date) : null; const key = toDateKey(task.scheduled_date);
return d && !Number.isNaN(d.getTime()) && d.getFullYear() === viewYear && d.getMonth() === viewMonth && t.work_order; 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 return workOrders
.filter((wo) => woIds.has(String(wo.id)) && Array.isArray(wo.invoices) && wo.invoices.length > 0) .filter((workOrder) => workOrderIds.has(String(workOrder.id)) && Array.isArray(workOrder.invoices))
.flatMap((wo) => .flatMap((workOrder) => (
wo.invoices.map((inv) => [ workOrder.invoices.map((invoice) => ([
inv.datum ? new Date(inv.datum).toLocaleDateString('hr-HR') : '-', invoice?.datum ? new Date(invoice.datum).toLocaleDateString('hr-HR') : '-',
inv.naziv_racuna || '-', invoice?.naziv_racuna || '-',
inv.lokacija || '-', invoice?.lokacija || '-',
inv.opis || '-', invoice?.opis || '-',
wo.display_code || '-', workOrder.display_code || '-',
]) ]))
) ));
.sort((a, b) => (a[0] || '').localeCompare(b[0] || '')); }, [reportType, tasks, viewYear, viewMonth, workOrders]);
}, [reportType, tasks, workOrders, viewYear, viewMonth]);
const selectedDayKey = selectedDay const selectedDayKey = selectedDay
? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}` ? `${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 selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : [];
const selectedDayNotes = selectedDayKey ? (notesByDate[selectedDayKey] ?? []) : []; const selectedDayNotes = selectedDayKey ? (notesByDate[selectedDayKey] ?? []) : [];
const today = new Date(); const today = new Date();
const totalScheduled = Object.values(tasksByDate).flat().length;
const totalNoteReminders = Object.values(notesByDate).flat().length;
function prevMonth() { function prevMonth() {
setSelectedDay(null); setSelectedDay(null);
if (viewMonth === 0) { setViewYear((y) => y - 1); setViewMonth(11); } if (viewMonth === 0) {
else setViewMonth((m) => m - 1); setViewYear((y) => y - 1);
setViewMonth(11);
return;
}
setViewMonth((m) => m - 1);
} }
function nextMonth() { function nextMonth() {
setSelectedDay(null); setSelectedDay(null);
if (viewMonth === 11) { setViewYear((y) => y + 1); setViewMonth(0); } if (viewMonth === 11) {
else setViewMonth((m) => m + 1); setViewYear((y) => y + 1);
setViewMonth(0);
return;
}
setViewMonth((m) => m + 1);
} }
function handleDayClick(day) { function handleDayClick(day) {
@@ -169,6 +311,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
function toggleReport(type) { function toggleReport(type) {
setReportType((prev) => (prev === type ? null : type)); setReportType((prev) => (prev === type ? null : type));
setManualEntryTarget(null);
} }
async function handleDownload() { async function handleDownload() {
@@ -177,7 +320,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
try { try {
if (reportType === 'servicer') { if (reportType === 'servicer') {
await downloadMonthlyServiserReport(viewYear, viewMonth + 1); await downloadMonthlyServiserReport(viewYear, viewMonth + 1);
} else { } else if (reportType === 'costs') {
await downloadMonthlyCostsReport(viewYear, viewMonth + 1); await downloadMonthlyCostsReport(viewYear, viewMonth + 1);
} }
} finally { } finally {
@@ -185,14 +328,40 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
} }
} }
const totalScheduled = Object.values(tasksByDate).flat().length; function handleServicerRowClick(row) {
const totalNoteReminders = Object.values(notesByDate).flat().length; 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 ( return (
<LeftSlideDrawer <LeftSlideDrawer
drawerId="calendar" drawerId="calendar"
title="đź“… Kalendar zadataka" title="Kalendar zadataka"
tabLabel="đź“… Kalendar" tabLabel="Kalendar"
tabTitle="Kalendar zadataka" tabTitle="Kalendar zadataka"
tabTopClassName="top-1/2" tabTopClassName="top-1/2"
backdropClassName="z-[45]" backdropClassName="z-[45]"
@@ -201,14 +370,11 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
panelWidth={panelWidth} panelWidth={panelWidth}
> >
{({ close }) => ( {({ close }) => (
<div className="flex flex-1 min-h-0 overflow-hidden"> <div className="relative flex min-h-0 flex-1 overflow-hidden">
{/* ── Calendar column (fixed 300 px) ── */}
<div <div
className="flex w-[300px] flex-shrink-0 flex-col overflow-hidden" className="flex w-[300px] flex-shrink-0 flex-col overflow-hidden"
style={reportOpen ? { borderRight: '1px solid var(--color-border-hairline, #e5e7eb)' } : {}} style={reportOpen ? { borderRight: '1px solid var(--color-border-hairline, #e5e7eb)' } : {}}
> >
{/* Report toggle buttons */}
<div className="flex gap-1.5 border-b border-border-hairline px-3 py-2"> <div className="flex gap-1.5 border-b border-border-hairline px-3 py-2">
<button <button
type="button" type="button"
@@ -219,9 +385,9 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
? 'bg-indigo-600 text-white' ? 'bg-indigo-600 text-white'
: 'bg-canvas-deep text-text-main hover:bg-indigo-50 hover:text-indigo-700', : 'bg-canvas-deep text-text-main hover:bg-indigo-50 hover:text-indigo-700',
].join(' ')} ].join(' ')}
title="Otvori panel za izradu mjesečnog izvještaja servisera" title="Otvori panel za mjesecni izvjestaj servisera"
> >
🊠Izvještaj servisera Izvjestaj servisera
</button> </button>
<button <button
type="button" type="button"
@@ -232,13 +398,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
? 'bg-emerald-600 text-white' ? 'bg-emerald-600 text-white'
: 'bg-canvas-deep text-text-main hover:bg-emerald-50 hover:text-emerald-700', : 'bg-canvas-deep text-text-main hover:bg-emerald-50 hover:text-emerald-700',
].join(' ')} ].join(' ')}
title="Otvori panel za izradu mjesečnog izvještaja troškova" title="Otvori panel za mjesecni izvjestaj troskova"
> >
🰠Izvještaj troškova Izvjestaj troskova
</button> </button>
</div> </div>
{/* Month navigation */}
<div className="flex items-center justify-between px-4 py-2"> <div className="flex items-center justify-between px-4 py-2">
<button <button
type="button" type="button"
@@ -246,7 +411,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep" className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
aria-label="Prethodni mjesec" aria-label="Prethodni mjesec"
> >
â &lt;
</button> </button>
<span className="text-sm font-medium text-text-main"> <span className="text-sm font-medium text-text-main">
{MONTH_NAMES[viewMonth]} {viewYear} {MONTH_NAMES[viewMonth]} {viewYear}
@@ -255,13 +420,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
type="button" type="button"
onClick={nextMonth} onClick={nextMonth}
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep" className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
aria-label="Sljedeći mjesec" aria-label="Sljedeci mjesec"
> >
â &gt;
</button> </button>
</div> </div>
{/* Calendar grid */}
<div className="px-3"> <div className="px-3">
<div className="grid grid-cols-7 text-center"> <div className="grid grid-cols-7 text-center">
{WEEKDAY_LABELS.map((label) => ( {WEEKDAY_LABELS.map((label) => (
@@ -279,16 +443,16 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
const taskCount = tasksByDate[key]?.length ?? 0; const taskCount = tasksByDate[key]?.length ?? 0;
const noteCount = notesByDate[key]?.length ?? 0; const noteCount = notesByDate[key]?.length ?? 0;
const isToday = const isToday =
today.getFullYear() === viewYear && today.getFullYear() === viewYear
today.getMonth() === viewMonth && && today.getMonth() === viewMonth
today.getDate() === day; && today.getDate() === day;
const isSelected = selectedDay === day; const isSelected = selectedDay === day;
return ( return (
<button <button
key={key} key={key}
type="button" type="button"
onClick={() => handleDayClick(day)} onClick={() => handleDayClick(day)}
title={hasTasks || hasNotes ? `${taskCount} zadatak/a, ${noteCount} bilješki` : undefined} title={hasTasks || hasNotes ? `${taskCount} zadatak/a, ${noteCount} biljeski` : undefined}
className={[ className={[
'relative mx-auto flex h-8 w-8 items-center justify-center rounded-full text-xs transition-colors', 'relative mx-auto flex h-8 w-8 items-center justify-center rounded-full text-xs transition-colors',
isSelected isSelected
@@ -317,10 +481,8 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
</div> </div>
</div> </div>
{/* Divider */}
<div className="mt-2 border-t border-border-hairline" /> <div className="mt-2 border-t border-border-hairline" />
{/* Task list for selected day OR hint */}
<div className="flex-1 overflow-y-auto px-4 py-3"> <div className="flex-1 overflow-y-auto px-4 py-3">
{selectedDay ? ( {selectedDay ? (
<> <>
@@ -328,12 +490,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
{formatDayLabel(viewYear, viewMonth, selectedDay)} {formatDayLabel(viewYear, viewMonth, selectedDay)}
</p> </p>
{selectedDayTasks.length === 0 && selectedDayNotes.length === 0 ? ( {selectedDayTasks.length === 0 && selectedDayNotes.length === 0 ? (
<p className="text-sm text-text-muted">Nema dogaÄaja za odabrani datum.</p> <p className="text-sm text-text-muted">Nema dogadjaja za odabrani datum.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{selectedDayNotes.length > 0 && ( {selectedDayNotes.length > 0 && (
<div> <div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700">Bilješke</p> <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700">Biljeske</p>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{selectedDayNotes.map((note) => ( {selectedDayNotes.map((note) => (
<li key={note.id} className="rounded-lg border border-violet-200 bg-violet-50 px-3 py-2 text-sm text-violet-900"> <li key={note.id} className="rounded-lg border border-violet-200 bg-violet-50 px-3 py-2 text-sm text-violet-900">
@@ -355,14 +517,16 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
<button <button
type="button" type="button"
onClick={() => handleTaskClick(task, close)} onClick={() => handleTaskClick(task, close)}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-left text-sm hover:bg-indigo-50 hover:border-indigo-200 transition-colors" className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-left text-sm transition-colors hover:border-indigo-200 hover:bg-indigo-50"
> >
<span className="block truncate font-medium text-text-main">{task.title || '-'}</span> <span className="block truncate font-medium text-text-main">
<span className="mt-0.5 block text-[11px] text-text-muted"> {task.title || '-'}
{task.assigned_to_name || '-'} ⢠{getStatusLabel(task.status || '-')}
</span> </span>
<span className="mt-0.5 block text-[11px] text-text-muted"> <span className="mt-0.5 block text-[11px] text-text-muted">
{getTaskCraneSerial(task)} ⢠{getTaskCraneKind(task)} ⢠{getTaskCraneOwner(task)} {task.assigned_to_name || '-'} | {getStatusLabel(task.status || '-')}
</span>
<span className="mt-0.5 block text-[11px] text-text-muted">
{getTaskCraneSerial(task)} | {getTaskCraneKind(task)} | {getTaskCraneOwner(task)}
</span> </span>
</button> </button>
</li> </li>
@@ -385,7 +549,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
)} )}
{totalNoteReminders > 0 && ( {totalNoteReminders > 0 && (
<p className="mt-1 text-xs font-medium text-violet-600"> <p className="mt-1 text-xs font-medium text-violet-600">
{totalNoteReminders} bilješki/podsjetnika u kalendaru. {totalNoteReminders} biljeski/podsjetnika u kalendaru.
</p> </p>
)} )}
{totalScheduled === 0 && ( {totalScheduled === 0 && (
@@ -398,14 +562,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
</div> </div>
</div> </div>
{/* ── Report panel (visible when reportType is set) ── */}
{reportOpen && ( {reportOpen && (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Report header */}
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-2"> <div className="flex items-center justify-between border-b border-border-hairline px-4 py-2">
<div> <div>
<span className="text-sm font-semibold text-text-main"> <span className="text-sm font-semibold text-text-main">
{reportType === 'servicer' ? '📊 Izvještaj servisera' : '💰 Izvještaj troškova'} {reportType === 'servicer' ? 'Mjesecni izvjestaj servisera' : 'Mjesecni izvjestaj troskova'}
</span> </span>
<span className="ml-2 text-xs text-text-muted"> <span className="ml-2 text-xs text-text-muted">
{MONTH_NAMES[viewMonth]} {viewYear} {MONTH_NAMES[viewMonth]} {viewYear}
@@ -413,45 +575,48 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
</div> </div>
<button <button
type="button" type="button"
onClick={() => downloadMonthlyServiserReport && handleDownload()} onClick={handleDownload}
disabled={downloading} disabled={downloading}
className="flex items-center gap-1.5 rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60" className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
> >
{downloading ? '⏳' : '📥'} Preuzmi DOCX {downloading ? 'Preuzimanje...' : 'Preuzmi DOCX'}
</button> </button>
</div> </div>
{/* Report info note */}
<p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted"> <p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted">
{reportType === 'servicer' {reportType === 'servicer'
? 'Pregled podataka za odabrani mjesec. Preuzmi DOCX za točan i formatiran izvještaj.' ? 'Tablica prikazuje sve dane u mjesecu. Klikni na red bez putnog naloga i odaberi status dana.'
: 'Pregled troškova za odabrani mjesec iz putnih naloga. Preuzmi DOCX za točan izvještaj.' : 'Pregled troskova iz racuna vezanih uz putne naloge u odabranom mjesecu.'}
}
</p> </p>
{/* Table */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{reportType === 'servicer' && ( {reportType === 'servicer' && (
servicerRows.length === 0 ? ( loadingManualEntries ? (
<p className="px-4 py-8 text-center text-sm text-text-muted"> <p className="px-4 py-8 text-center text-sm text-text-muted">Ucitam dnevne unose...</p>
Nema zadataka za {MONTH_NAMES[viewMonth]} {viewYear}.
</p>
) : ( ) : (
<table className="w-full min-w-[580px] border-collapse text-[11px]"> <table className="w-full min-w-[980px] border-collapse text-[11px]">
<thead> <thead>
<tr className="bg-canvas-deep"> <tr className="bg-canvas-deep">
{SERVICER_HEADERS.map((h) => ( {SERVICER_HEADERS.map((header) => (
<th key={h} className="border border-border-hairline px-2 py-1.5 text-left font-semibold text-text-muted whitespace-nowrap"> <th key={header} className="border border-border-hairline px-2 py-1.5 text-left font-semibold text-text-muted whitespace-nowrap">
{h} {header}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{servicerRows.map((row, i) => ( {servicerRows.map((row, index) => (
<tr key={i} className={i % 2 === 0 ? 'bg-canvas-base' : 'bg-canvas-deep'}> <tr
{row.map((cell, j) => ( key={row.key}
<td key={j} className="border border-border-hairline px-2 py-1 text-text-main whitespace-nowrap"> onClick={() => 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) => (
<td key={`${row.key}-${cellIndex}`} className="border border-border-hairline px-2 py-1 text-text-main whitespace-nowrap">
{cell} {cell}
</td> </td>
))} ))}
@@ -461,29 +626,28 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
</table> </table>
) )
)} )}
{reportType === 'costs' && ( {reportType === 'costs' && (
costsRows.length === 0 ? ( costsRows.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-text-muted"> <p className="px-4 py-8 text-center text-sm text-text-muted">
Nema troškova za {MONTH_NAMES[viewMonth]} {viewYear}. Nema troskova za {MONTH_NAMES[viewMonth]} {viewYear}.
<br />
<span className="text-[11px]">Napomena: prikaz ovisi o dostupnim podacima raÄŤuna u putnim nalozima.</span>
</p> </p>
) : ( ) : (
<table className="w-full min-w-[400px] border-collapse text-[11px]"> <table className="w-full min-w-[640px] border-collapse text-[11px]">
<thead> <thead>
<tr className="bg-canvas-deep"> <tr className="bg-canvas-deep">
{['DATUM', 'NAZIV RAÄŚUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG'].map((h) => ( {COST_HEADERS.map((header) => (
<th key={h} className="border border-border-hairline px-2 py-1.5 text-left font-semibold text-text-muted whitespace-nowrap"> <th key={header} className="border border-border-hairline px-2 py-1.5 text-left font-semibold text-text-muted whitespace-nowrap">
{h} {header}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{costsRows.map((row, i) => ( {costsRows.map((row, index) => (
<tr key={i} className={i % 2 === 0 ? 'bg-canvas-base' : 'bg-canvas-deep'}> <tr key={`${row[0]}-${row[1]}-${index}`} className={index % 2 === 0 ? 'bg-canvas-base' : 'bg-canvas-deep'}>
{row.map((cell, j) => ( {row.map((cell, cellIndex) => (
<td key={j} className="border border-border-hairline px-2 py-1 text-text-main whitespace-nowrap"> <td key={cellIndex} className="border border-border-hairline px-2 py-1 text-text-main whitespace-nowrap">
{cell} {cell}
</td> </td>
))} ))}
@@ -496,6 +660,59 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
</div> </div>
</div> </div>
)} )}
{manualEntryTarget && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/20">
<div className="w-full max-w-sm rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-main">Odaberi vrstu unosa</h3>
<p className="mt-1 text-xs text-text-muted">
{formatDateFromKey(manualEntryTarget.key)} nema putni nalog. Odaberi status dana.
</p>
</div>
<button
type="button"
onClick={() => setManualEntryTarget(null)}
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
x
</button>
</div>
<div className="mt-4 grid gap-2">
<button
type="button"
disabled={savingManualEntry}
onClick={() => handleSaveManualEntry('office')}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
>
<span className="block font-medium text-text-main">Rad u uredu</span>
<span className="block text-xs text-text-muted">08:00 - 16:00, 8h, Zagreb (ured)</span>
</button>
<button
type="button"
disabled={savingManualEntry}
onClick={() => handleSaveManualEntry('sick_leave')}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
>
<span className="block font-medium text-text-main">Bolovanje</span>
<span className="block text-xs text-text-muted">Ukupno 8h</span>
</button>
<button
type="button"
disabled={savingManualEntry}
onClick={() => handleSaveManualEntry('vacation')}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
>
<span className="block font-medium text-text-main">Godisnji</span>
<span className="block text-xs text-text-muted">Ukupno 8h</span>
</button>
</div>
</div>
</div>
)}
</div> </div>
)} )}
</LeftSlideDrawer> </LeftSlideDrawer>

View File

@@ -64,6 +64,17 @@ export default function TaskCalendarPortal() {
)); ));
}, [workOrders, isSupervisorNotServiser, user?.id]); }, [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(() => { useEffect(() => {
if (!user?.id) return; if (!user?.id) return;
fetchServiceNotes().catch(() => {}); fetchServiceNotes().catch(() => {});
@@ -103,7 +114,7 @@ export default function TaskCalendarPortal() {
<TaskCalendarWidget <TaskCalendarWidget
tasks={tasks} tasks={tasks}
notes={notes} notes={notes}
workOrders={activeWorkOrders} workOrders={calendarWorkOrders}
onTaskClick={handleTaskClick} onTaskClick={handleTaskClick}
/> />
<ServiceNotesWidget <ServiceNotesWidget

View File

@@ -913,6 +913,30 @@ export async function downloadWorkOrderInvoicesDocx(workOrderId) {
} }
} }
export async function fetchMonthlyServicerEntries(year, month) {
const payload = await api.get(
`fleet/monthly-servicer-day-entries/?year=${year}&month=${month}`
);
return Array.isArray(payload) ? payload : (payload?.results ?? []);
}
export async function upsertMonthlyServicerEntry(data = {}) {
if (!data.entry_date) {
throw new Error('Datum dnevnog unosa je obavezan.');
}
if (!data.entry_type) {
throw new Error('Vrsta dnevnog unosa je obavezna.');
}
try {
const response = await api.post('fleet/monthly-servicer-day-entries/', data);
showToast('Dnevni unos je uspjesno spremljen.', 'success');
return response;
} catch (err) {
showToast(err?.message || 'Spremanje dnevnog unosa nije uspjelo.', 'error');
throw err;
}
}
export async function downloadMonthlyServiserReport(year, month) { export async function downloadMonthlyServiserReport(year, month) {
try { try {
const blob = await api.get( const blob = await api.get(