From 3ad536a23e41cec2fac531e3589ef30d79d1f8d0 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Mon, 13 Jul 2026 00:36:28 +0200 Subject: [PATCH] interaktivna Tablica radnih sati, db --- backend/core/settings/development.py | 12 +- backend/core/settings/production.py | 2 +- backend/modules/fleet/views.py | 141 +++--- backend/modules/task_management/admin.py | 63 ++- .../0004_tasktemplate_taskworkentry.py | 72 +++ .../migrations/0005_seed_task_templates.py | 100 ++++ .../0006_add_finished_task_status.py | 39 ++ .../migrations/0007_taskworkhourstable.py | 29 ++ backend/modules/task_management/models.py | 111 +++- .../modules/task_management/serializers.py | 111 +++- backend/modules/task_management/services.py | 91 +++- .../task_management/tests/test_serializers.py | 12 + .../task_management/tests/test_services.py | 86 +++- .../task_management/tests/test_views.py | 35 +- .../tests/test_work_hours_table.py | 97 ++++ backend/modules/task_management/views.py | 69 ++- docker-compose.dev.yml | 10 +- .../components/dashboard/TaskCreateModal.jsx | 176 +++++-- .../dashboard/TaskWorkHoursTableModal.jsx | 476 ++++++++++++++++++ .../dashboard/WorkOrderInvoicesPdfPage.jsx | 40 ++ frontend/src/stores/fleetDashboardStore.js | 9 + frontend/src/stores/taskStore.js | 22 +- 22 files changed, 1629 insertions(+), 174 deletions(-) create mode 100644 backend/modules/task_management/migrations/0004_tasktemplate_taskworkentry.py create mode 100644 backend/modules/task_management/migrations/0005_seed_task_templates.py create mode 100644 backend/modules/task_management/migrations/0006_add_finished_task_status.py create mode 100644 backend/modules/task_management/migrations/0007_taskworkhourstable.py create mode 100644 backend/modules/task_management/tests/test_work_hours_table.py create mode 100644 frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx diff --git a/backend/core/settings/development.py b/backend/core/settings/development.py index e414f30..b18f48b 100644 --- a/backend/core/settings/development.py +++ b/backend/core/settings/development.py @@ -46,4 +46,14 @@ INSTALLED_APPS += ['debug_toolbar'] MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] INTERNAL_IPS = ['127.0.0.1'] -CELERY_TASK_ALWAYS_EAGER = True \ No newline at end of file +CELERY_TASK_ALWAYS_EAGER = True + +# URL kojim će preglednik dohvaćati statiku +STATIC_URL = '/static/' + +# Direktorij gdje će 'collectstatic' skupljati sve statiÄŤke datoteke (za produkciju) +STATIC_ROOT = BASE_DIR / '/tmp/staticfiles' + +# (Opcionalno, ali preporuÄŤeno za ERP) Ako koristiš i medije (dokumente) +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' \ No newline at end of file diff --git a/backend/core/settings/production.py b/backend/core/settings/production.py index ac9cddf..ee3ad16 100644 --- a/backend/core/settings/production.py +++ b/backend/core/settings/production.py @@ -39,4 +39,4 @@ CSRF_TRUSTED_ORIGINS = [ STATIC_URL = '/static/' STATIC_ROOT = '/app/staticfiles' MEDIA_URL = '/media/' -MEDIA_ROOT = '/app/media' +MEDIA_ROOT = '/app/media' \ No newline at end of file diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 710e63d..30867a2 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -103,7 +103,9 @@ def _work_order_related_tasks_queryset(work_order): task_ids = list({*direct_task_ids, *inferred_task_ids}) if not task_ids: return Task.objects.none() - return Task.objects.filter(id__in=task_ids, is_active=True).select_related('assigned_to', 'vehicle', 'work_order').order_by('-created_at') + return Task.objects.filter(id__in=task_ids, is_active=True).select_related( + 'assigned_to', 'vehicle', 'work_order', 'work_hours_table' + ).order_by('-created_at') def _can_access_service_record(user, service_record): service_record_id = getattr(service_record, 'pk', service_record) @@ -523,31 +525,6 @@ def _build_work_order_service_records_pdf(work_order): HEADER_H = 86 FOOTER_H = 72 MARGIN = 28 - DAY_MAP = { - 0: 'PO', - 1: 'UT', - 2: 'SR', - 3: 'ČET', - 4: 'PET', - 5: 'SUB', - 6: 'NED', - } - - def _fmt_date(value): - if not value: - return '-' - return value.strftime('%d.%m.%Y') - - def _fmt_time(value): - if not value: - return '-' - return value.astimezone(timezone.get_current_timezone()).strftime('%H:%M') - - def _hours_between(start_at, end_at): - if not start_at or not end_at: - return 0.0 - delta = end_at - start_at - return max(0.0, round(delta.total_seconds() / 3600.0, 2)) service_rows = list( VehicleServiceRecord.objects.filter( @@ -559,8 +536,6 @@ def _build_work_order_service_records_pdf(work_order): .order_by('service_date', 'created_at') ) related_tasks = list(_work_order_related_tasks_queryset(work_order)) - travel_hours = _hours_between(work_order.travel_start_at, work_order.travel_end_at) - travel_km = work_order.distance if work_order.distance is not None else 0 task_titles = [row.task.title for row in service_rows if getattr(row, 'task', None) and row.task.title] notes_text = ' | '.join(task_titles[:2]) if task_titles else (work_order.notes or '') @@ -627,6 +602,58 @@ def _build_work_order_service_records_pdf(work_order): description_style.fontSize = 8.2 description_style.leading = 10.4 + def _normalize_hours_table_row(row): + if not isinstance(row, dict): + return None + return { + 'day': str(row.get('day', '') or '').strip() or '-', + 'date': str(row.get('date', '') or '').strip() or '-', + 'work_time': " - ".join( + [ + str(row.get('work_time_from', '') or '').strip() or '-', + str(row.get('work_time_to', '') or '').strip() or '-', + ] + ), + 'travel_time': " - ".join( + [ + str(row.get('travel_time_from', '') or '').strip() or '-', + str(row.get('travel_time_to', '') or '').strip() or '-', + ] + ), + 'break_hours': str(row.get('break_hours', '') or '').strip() or '-', + 'work_hours': str(row.get('work_hours', '') or '').strip() or '-', + 'travel_hours': str(row.get('travel_hours', '') or '').strip() or '-', + 'places': "\n".join([ + f"Polazak: {str(row.get('departure_place', '') or '').strip() or '-'}", + f"Dolazak: {str(row.get('arrival_place', '') or '').strip() or '-'}", + ]), + 'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-', + } + + def _parse_decimal(value): + if value in (None, ''): + return 0.0 + try: + return float(str(value).replace(',', '.')) + except (TypeError, ValueError): + return 0.0 + + def _format_decimal(value): + return f"{float(value):.1f}".replace('.', ',') + + normalized_hours_rows = [] + for task in related_tasks: + table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) + if not isinstance(table_data, dict): + continue + rows = table_data.get('rows', []) + if not isinstance(rows, list): + continue + for raw_row in rows: + normalized = _normalize_hours_table_row(raw_row) + if normalized: + normalized_hours_rows.append(normalized) + y = content_top table1 = [ @@ -683,42 +710,29 @@ def _build_work_order_service_records_pdf(work_order): "Kilometri\nvozila", ] table3_rows = [table3_headers] + total_hours_travel = 0.0 + total_vehicle_km = 0.0 - if work_order.travel_start_at or work_order.travel_end_at: - day_idx = work_order.travel_start_at.weekday() if work_order.travel_start_at else None - table3_rows.append([ - DAY_MAP.get(day_idx, '-'), - _fmt_date(work_order.travel_start_at.date() if work_order.travel_start_at else work_order.date), - "-", - f"{_fmt_time(work_order.travel_start_at)} - {_fmt_time(work_order.travel_end_at)}", - "-", - "-", - str(travel_hours).replace('.', ','), - work_order.location or '-', - str(travel_km or '-'), - ]) + if normalized_hours_rows: + for row in normalized_hours_rows: + table3_rows.append([ + row['day'], + row['date'], + row['work_time'], + row['travel_time'], + row['break_hours'], + row['work_hours'], + row['travel_hours'], + row['places'], + row['vehicle_km'], + ]) + total_hours_travel += _parse_decimal(row['travel_hours']) + total_vehicle_km += _parse_decimal(row['vehicle_km']) + else: + for _ in range(12): + table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"]) - for record in service_rows[:6]: - day_idx = record.service_date.weekday() if record.service_date else None - table3_rows.append([ - DAY_MAP.get(day_idx, '-'), - _fmt_date(record.service_date), - "-", - "-", - "-", - "-", - "-", - getattr(record.vehicle, 'registration_number', '-') or '-', - str(record.mileage or '-'), - ]) - - if len(table3_rows) == 1: - table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "-", "-"]) - - total_hours_travel = str(travel_hours).replace('.', ',') - for _ in range(6): - table3_rows.append(["", "", "", "", "", "", "", "", ""]) - table3_rows.append(["UKUPNO", "", "", "", "", "", total_hours_travel, "", str(travel_km or 0)]) + table3_rows.append(["UKUPNO", "", "", "", "", "", _format_decimal(total_hours_travel), "", str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ',')]) y = draw_table( y, table3_rows, @@ -739,6 +753,8 @@ def _build_work_order_service_records_pdf(work_order): ('LEFTPADDING', (0, 0), (-1, -1), 3), ('RIGHTPADDING', (0, 0), (-1, -1), 3), ('LEFTPADDING', (0, -1), (5, -1), 12), + ('VALIGN', (7, 1), (7, -2), 'TOP'), + ('ALIGN', (7, 1), (7, -2), 'LEFT'), ]), ) @@ -1449,6 +1465,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet): 'description': task.description, 'assigned_to_name': _user_display_name(task.assigned_to), 'vehicle_registration': getattr(task.vehicle, 'registration_number', None), + 'work_hours_table': getattr(getattr(task, 'work_hours_table', None), 'data', None), 'service_records': records_payload, }) diff --git a/backend/modules/task_management/admin.py b/backend/modules/task_management/admin.py index b049937..ed3b8aa 100644 --- a/backend/modules/task_management/admin.py +++ b/backend/modules/task_management/admin.py @@ -1,21 +1,56 @@ -# backend/modules/task_management/admin.py +# backend/modules/task_management/admin.py from django.contrib import admin -from .models import Task + +from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable + + +class TaskWorkEntryInline(admin.TabularInline): + model = TaskWorkEntry + extra = 0 + fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'template_entry') + readonly_fields = ('template_entry',) + + +class TaskWorkHoursTableInline(admin.StackedInline): + model = TaskWorkHoursTable + extra = 0 + fields = ('data', 'is_active') + + +class TaskTemplateEntryInline(admin.TabularInline): + model = TaskTemplateEntry + extra = 0 + fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'is_active') + @admin.register(Task) class TaskAdmin(admin.ModelAdmin): - """ - Konfiguracija prikaza modela u Admin panelu. - """ - # Koja polja želimo vidjeti u tabličnom prikazu - list_display = ('title', 'status', 'assigned_to', 'work_order', 'created_at') - - # Omogućuje filtriranje s desne strane + list_display = ('title', 'status', 'assigned_to', 'vehicle', 'work_order', 'created_at') list_filter = ('status', 'created_at', 'work_order') - - # Omogućuje pretraživanje po tekstu search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number') - - # Polja koja su samo za čitanje (npr. UUID koji se ne smije mijenjati) - readonly_fields = ('id', 'created_at', 'updated_at') \ No newline at end of file + readonly_fields = ('id', 'created_at', 'updated_at') + inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline) + + +@admin.register(TaskTemplate) +class TaskTemplateAdmin(admin.ModelAdmin): + list_display = ('code', 'title', 'status', 'is_active', 'created_at') + list_filter = ('status', 'is_active', 'created_at') + search_fields = ('code', 'title', 'description') + inlines = (TaskTemplateEntryInline,) + + +@admin.register(TaskWorkEntry) +class TaskWorkEntryAdmin(admin.ModelAdmin): + list_display = ('task', 'sort_order', 'title', 'template_entry', 'created_at') + list_filter = ('created_at',) + search_fields = ('task__title', 'title', 'active_faults', 'explanation_hr') + readonly_fields = ('id', 'created_at', 'updated_at') + + +@admin.register(TaskWorkHoursTable) +class TaskWorkHoursTableAdmin(admin.ModelAdmin): + list_display = ('task', 'updated_at', 'is_active') + search_fields = ('task__title', 'task__vehicle__registration_number') + readonly_fields = ('id', 'created_at', 'updated_at') diff --git a/backend/modules/task_management/migrations/0004_tasktemplate_taskworkentry.py b/backend/modules/task_management/migrations/0004_tasktemplate_taskworkentry.py new file mode 100644 index 0000000..854f067 --- /dev/null +++ b/backend/modules/task_management/migrations/0004_tasktemplate_taskworkentry.py @@ -0,0 +1,72 @@ +# Generated by Django 5.2.15 on 2026-07-12 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task_management', '0003_task_vehicle'), + ] + + operations = [ + migrations.CreateModel( + name='TaskTemplate', + 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')), + ('code', models.SlugField(max_length=64, unique=True)), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20)), + ], + options={ + 'ordering': ['title'], + }, + ), + migrations.CreateModel( + name='TaskTemplateEntry', + 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')), + ('sort_order', models.PositiveSmallIntegerField(default=1)), + ('title', models.CharField(max_length=255)), + ('work_description', models.TextField(blank=True)), + ('active_faults', models.TextField(blank=True, help_text='Aktivne greške.')), + ('explanation_hr', models.TextField(blank=True, help_text='Obrazloženje na hrvatskom jeziku.')), + ('task_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='task_management.tasktemplate')), + ], + options={ + 'ordering': ['sort_order', 'created_at'], + }, + ), + migrations.CreateModel( + name='TaskWorkEntry', + 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')), + ('sort_order', models.PositiveSmallIntegerField(default=1)), + ('title', models.CharField(max_length=255)), + ('work_description', models.TextField(blank=True)), + ('active_faults', models.TextField(blank=True)), + ('explanation_hr', models.TextField(blank=True)), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_entries', to='task_management.task')), + ('template_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_work_entries', to='task_management.tasktemplateentry')), + ], + options={ + 'ordering': ['sort_order', 'created_at'], + }, + ), + migrations.AddConstraint( + model_name='tasktemplateentry', + constraint=models.UniqueConstraint(fields=('task_template', 'sort_order'), name='task_template_entry_sort_order_unique'), + ), + ] diff --git a/backend/modules/task_management/migrations/0005_seed_task_templates.py b/backend/modules/task_management/migrations/0005_seed_task_templates.py new file mode 100644 index 0000000..f915322 --- /dev/null +++ b/backend/modules/task_management/migrations/0005_seed_task_templates.py @@ -0,0 +1,100 @@ +# Generated by Django 5.2.15 on 2026-07-12 + +from django.db import migrations + + +def seed_task_templates(apps, schema_editor): + TaskTemplate = apps.get_model('task_management', 'TaskTemplate') + TaskTemplateEntry = apps.get_model('task_management', 'TaskTemplateEntry') + + templates = [ + { + 'code': 'zamjena-ulja-500h', + 'title': 'Zamjena ulja nakon 500h', + 'description': 'Redovni servis uljnog sustava nakon 500 radnih sati.', + 'status': 'aktivan', + 'entries': [ + ('Provjera razine i stanja ulja', 'Izvršena kontrola razine i vizualna kontrola kontaminacije ulja.', 'Nema aktivnih grešaka.', 'Sustav uredan, bez upozorenja.'), + ('Ispuštanje starog ulja', 'Sigurno ispušteno staro ulje prema servisnoj proceduri.', 'Nema aktivnih grešaka.', 'Ulje zamijenjeno prema uputama proizvođača.'), + ('Zamjena filtera ulja', 'Demontiran stari i ugrađen novi filter ulja.', 'Nema aktivnih grešaka.', 'Filter promijenjen i sustav pripremljen za punjenje.'), + ('Punjenje i test sustava', 'Sustav napunjen novim uljem i odrađen probni rad.', 'Nema aktivnih grešaka.', 'Nakon testa nema curenja ni grešaka.'), + ], + }, + { + 'code': 'zamjena-ulja-1000h', + 'title': 'Zamjena ulja nakon 1000h', + 'description': 'Veliki servis uljnog sustava nakon 1000 radnih sati.', + 'status': 'aktivan', + 'entries': [ + ('Dijagnostika sustava prije servisa', 'Očitani parametri rada i zabilježeno početno stanje.', 'Provjeriti povremeni pad tlaka.', 'Prijevod poruke greške i potvrda simptoma na hrvatskom.'), + ('Zamjena ulja i glavnih filtera', 'Zamijenjeno ulje i svi pripadajući filter elementi.', 'Nema kritičnih aktivnih grešaka.', 'Servisni interval resetiran.'), + ('Kontrola brtvi i spojeva', 'Pregledani spojevi i brtve na mogućnost curenja.', 'Nema aktivnih grešaka.', 'Spojevi su suhi i ispravni.'), + ('Test pod opterećenjem', 'Izvršen funkcionalni test pod radnim opterećenjem.', 'Nema aktivnih grešaka.', 'Sustav stabilan pri opterećenju.'), + ], + }, + { + 'code': 'promjena-senzora', + 'title': 'Promjena senzora', + 'description': 'Zamjena neispravnog senzora i verifikacija rada.', + 'status': 'aktivan', + 'entries': [ + ('Identifikacija neispravnog senzora', 'Dijagnostikom potvrđena neispravnost senzora.', 'Aktivna greška senzora.', 'Greška prevedena s engleskog i objašnjena korisniku.'), + ('Demontaža i montaža novog senzora', 'Neispravan senzor uklonjen i ugrađen novi originalni dio.', 'Nema novih aktivnih grešaka.', 'Montaža izvedena prema specifikaciji.'), + ('Kalibracija senzora', 'Izvršena kalibracija i sinkronizacija s upravljačkom jedinicom.', 'Nema aktivnih grešaka.', 'Kalibracija uspješna.'), + ('Završno testiranje', 'Provjeren odziv sustava tijekom rada.', 'Nema aktivnih grešaka.', 'Senzor radi unutar tolerancije.'), + ], + }, + { + 'code': 'aktivna-osovina-ukoso', + 'title': 'Aktivna osovina ide ukoso', + 'description': 'Dijagnostika i otklanjanje kvara aktivne osovine.', + 'status': 'aktivan', + 'entries': [ + ('Dijagnostika geometrije osovine', 'Provjereni kutovi i odstupanja geometrije osovine.', 'Aktivna greška upravljanja osovinom.', 'Opis greške preveden i pojašnjen na hrvatskom.'), + ('Pregled hidrauličkih komponenti', 'Provjeren tlak i stanje hidrauličkih vodova.', 'Mogući pad tlaka na upravljačkom krugu.', 'Predložena zamjena kritičnog ventila.'), + ('Korekcija i podešavanje', 'Izvršeno mehaničko/hidrauličko podešavanje osovine.', 'Nema aktivnih grešaka nakon podešavanja.', 'Osovina vraćena u radne tolerancije.'), + ('Probna vožnja i završna potvrda', 'Izvršena probna vožnja i kontrola stabilnosti pravca.', 'Nema aktivnih grešaka.', 'Kvar otklonjen, vozilo spremno za rad.'), + ], + }, + ] + + for template_data in templates: + entries = template_data.pop('entries') + template, _ = TaskTemplate.objects.update_or_create( + code=template_data['code'], + defaults=template_data, + ) + TaskTemplateEntry.objects.filter(task_template=template).delete() + for idx, entry in enumerate(entries, start=1): + title, work_description, active_faults, explanation_hr = entry + TaskTemplateEntry.objects.create( + task_template=template, + sort_order=idx, + title=title, + work_description=work_description, + active_faults=active_faults, + explanation_hr=explanation_hr, + ) + + +def reverse_seed_task_templates(apps, schema_editor): + TaskTemplate = apps.get_model('task_management', 'TaskTemplate') + TaskTemplate.objects.filter( + code__in=[ + 'zamjena-ulja-500h', + 'zamjena-ulja-1000h', + 'promjena-senzora', + 'aktivna-osovina-ukoso', + ] + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('task_management', '0004_tasktemplate_taskworkentry'), + ] + + operations = [ + migrations.RunPython(seed_task_templates, reverse_seed_task_templates), + ] diff --git a/backend/modules/task_management/migrations/0006_add_finished_task_status.py b/backend/modules/task_management/migrations/0006_add_finished_task_status.py new file mode 100644 index 0000000..d97fdcb --- /dev/null +++ b/backend/modules/task_management/migrations/0006_add_finished_task_status.py @@ -0,0 +1,39 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task_management', '0005_seed_task_templates'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='status', + field=models.CharField( + choices=[ + ('aktivan', 'Aktivan'), + ('servis', 'U tijeku'), + ('zavrsen', 'Završen'), + ('neaktivan', 'Otkazano'), + ], + default='aktivan', + max_length=20, + ), + ), + migrations.AlterField( + model_name='tasktemplate', + name='status', + field=models.CharField( + choices=[ + ('aktivan', 'Aktivan'), + ('servis', 'U tijeku'), + ('zavrsen', 'Završen'), + ('neaktivan', 'Otkazano'), + ], + default='aktivan', + max_length=20, + ), + ), + ] diff --git a/backend/modules/task_management/migrations/0007_taskworkhourstable.py b/backend/modules/task_management/migrations/0007_taskworkhourstable.py new file mode 100644 index 0000000..e391655 --- /dev/null +++ b/backend/modules/task_management/migrations/0007_taskworkhourstable.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task_management', '0006_add_finished_task_status'), + ] + + operations = [ + migrations.CreateModel( + name='TaskWorkHoursTable', + 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')), + ('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice radnih sati')), + ('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='work_hours_table', to='task_management.task', verbose_name='Radni zadatak')), + ], + options={ + 'verbose_name': 'Tablica radnih sati', + 'verbose_name_plural': 'Tablice radnih sati', + 'ordering': ['-updated_at'], + }, + ), + ] diff --git a/backend/modules/task_management/models.py b/backend/modules/task_management/models.py index cb7d8e2..3129201 100644 --- a/backend/modules/task_management/models.py +++ b/backend/modules/task_management/models.py @@ -1,10 +1,11 @@ -# backend/modules/task_management/models.py +# backend/modules/task_management/models.py from django.db import models from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -from core.base_models import BaseModel # Uvozimo naš Base +from core.base_models import BaseModel + class Task(BaseModel): """ @@ -13,6 +14,7 @@ class Task(BaseModel): STATUS_CHOICES = [ ('aktivan', 'Aktivan'), ('servis', 'U tijeku'), + ('zavrsen', 'Završen'), ('neaktivan', 'Otkazano'), ] title = models.CharField(max_length=255) @@ -23,9 +25,9 @@ class Task(BaseModel): default='aktivan' ) assigned_to = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, related_name='tasks' ) vehicle = models.ForeignKey( @@ -53,7 +55,102 @@ class Task(BaseModel): def clean(self): super().clean() - if self.status == 'neaktivan' and not self.work_order_id: + if self.status in {'neaktivan', 'zavrsen'} and not self.work_order_id: raise ValidationError({ 'work_order': "Putni nalog je obavezan prije zatvaranja zadatka." - }) \ No newline at end of file + }) + + +class TaskWorkHoursTable(BaseModel): + """Interaktivna tablica radnih sati povezana s radnim zadatkom.""" + + task = models.OneToOneField( + Task, + on_delete=models.CASCADE, + related_name='work_hours_table', + verbose_name=_("Radni zadatak"), + ) + data = models.JSONField( + null=True, + blank=True, + default=None, + verbose_name=_("Podaci tablice radnih sati"), + ) + + class Meta: + ordering = ['-updated_at'] + verbose_name = _("Tablica radnih sati") + verbose_name_plural = _("Tablice radnih sati") + + def __str__(self): + return f"Work hours table for task {self.task_id}" + + +class TaskTemplate(BaseModel): + """Predložak za brzo kreiranje servisnog radnog zadatka.""" + + code = models.SlugField(max_length=64, unique=True) + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=Task.STATUS_CHOICES, default='aktivan') + + class Meta: + ordering = ['title'] + + def __str__(self): + return f"{self.title} ({self.code})" + + +class TaskTemplateEntry(BaseModel): + """Predlošci radnih unosa koji se generiraju prilikom kreiranja Taska.""" + + task_template = models.ForeignKey( + TaskTemplate, + on_delete=models.CASCADE, + related_name='entries', + ) + sort_order = models.PositiveSmallIntegerField(default=1) + title = models.CharField(max_length=255) + work_description = models.TextField(blank=True) + active_faults = models.TextField(blank=True, help_text=_("Aktivne greške.")) + explanation_hr = models.TextField(blank=True, help_text=_("Obrazloženje na hrvatskom jeziku.")) + + class Meta: + ordering = ['sort_order', 'created_at'] + constraints = [ + models.UniqueConstraint( + fields=['task_template', 'sort_order'], + name='task_template_entry_sort_order_unique', + ) + ] + + def __str__(self): + return f"{self.task_template.code} #{self.sort_order} - {self.title}" + + +class TaskWorkEntry(BaseModel): + """Radni unos na konkretnom Tasku (generiran iz templatea ili ručno).""" + + task = models.ForeignKey( + Task, + on_delete=models.CASCADE, + related_name='work_entries', + ) + template_entry = models.ForeignKey( + TaskTemplateEntry, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='task_work_entries', + ) + sort_order = models.PositiveSmallIntegerField(default=1) + title = models.CharField(max_length=255) + work_description = models.TextField(blank=True) + active_faults = models.TextField(blank=True) + explanation_hr = models.TextField(blank=True) + + class Meta: + ordering = ['sort_order', 'created_at'] + + def __str__(self): + return f"{self.task_id} #{self.sort_order} - {self.title}" diff --git a/backend/modules/task_management/serializers.py b/backend/modules/task_management/serializers.py index b74f3b7..5023f49 100644 --- a/backend/modules/task_management/serializers.py +++ b/backend/modules/task_management/serializers.py @@ -1,12 +1,92 @@ -# backend/modules/task_management/serializers.py +# backend/modules/task_management/serializers.py from rest_framework import serializers -from .models import Task +from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable + + +WORK_HOURS_ROW_FIELDS = [ + 'day', + 'date', + 'work_time_from', + 'work_time_to', + 'travel_time_from', + 'travel_time_to', + 'break_hours', + 'work_hours', + 'travel_hours', + 'departure_place', + 'arrival_place', + 'vehicle_km', +] + + +class TaskWorkHoursTableSerializer(serializers.ModelSerializer): + class Meta: + model = TaskWorkHoursTable + fields = ['id', 'task', 'data', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def _normalize_row(self, row): + normalized = {} + for field in WORK_HOURS_ROW_FIELDS: + value = row.get(field, '') + normalized[field] = '' if value is None else str(value).strip() + return normalized + + def validate_data(self, value): + if value is None: + return None + if not isinstance(value, dict): + raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt ili null.") + + rows = value.get('rows', []) + if not isinstance(rows, list): + raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."}) + + normalized_rows = [] + for row in rows[:30]: + if not isinstance(row, dict): + raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."}) + normalized_rows.append(self._normalize_row(row)) + + return {'rows': normalized_rows} + + +class TaskWorkEntrySerializer(serializers.ModelSerializer): + class Meta: + model = TaskWorkEntry + fields = [ + 'id', 'task', 'template_entry', 'sort_order', 'title', + 'work_description', 'active_faults', 'explanation_hr', + 'created_at', + ] + read_only_fields = ['id', 'created_at'] + + +class TaskTemplateEntrySerializer(serializers.ModelSerializer): + class Meta: + model = TaskTemplateEntry + fields = [ + 'id', 'sort_order', 'title', 'work_description', + 'active_faults', 'explanation_hr', + ] + read_only_fields = ['id'] + + +class TaskTemplateSerializer(serializers.ModelSerializer): + entries = TaskTemplateEntrySerializer(many=True, read_only=True) + + class Meta: + model = TaskTemplate + fields = ['id', 'code', 'title', 'description', 'status', 'entries'] + read_only_fields = ['id'] + class TaskSerializer(serializers.ModelSerializer): assigned_to_name = serializers.SerializerMethodField() work_order_label = serializers.SerializerMethodField() vehicle_registration = serializers.SerializerMethodField() + template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True) class Meta: model = Task @@ -15,27 +95,48 @@ class TaskSerializer(serializers.ModelSerializer): 'assigned_to', 'assigned_to_name', 'vehicle', 'vehicle_registration', 'work_order', 'work_order_label', + 'template_id', 'created_at', ] read_only_fields = ['id', 'created_at'] + extra_kwargs = { + 'title': {'required': False, 'allow_blank': True}, + 'description': {'required': False, 'allow_blank': True}, + 'status': {'required': False}, + 'work_order': {'required': False, 'allow_null': True}, + 'vehicle': {'required': False, 'allow_null': True}, + } def validate_title(self, value): - """Primjer custom validacije: naziv ne smije biti prekratak.""" + if not value: + return value if len(value) < 3: raise serializers.ValidationError("Naslov zadatka mora imati barem 3 znaka.") return value def validate(self, attrs): current = getattr(self, 'instance', None) + template_id = attrs.get('template_id') status_value = attrs.get('status', getattr(current, 'status', 'aktivan')) work_order = attrs.get('work_order', getattr(current, 'work_order', None)) vehicle = attrs.get('vehicle', getattr(current, 'vehicle', None)) + title = (attrs.get('title', getattr(current, 'title', '')) or '').strip() + + if template_id: + template = TaskTemplate.objects.filter(id=template_id, is_active=True).first() + if not template: + raise serializers.ValidationError({'template_id': 'Odabrani template ne postoji ili nije aktivan.'}) if current is None and not vehicle: raise serializers.ValidationError({ 'vehicle': "Dizalica je obavezna pri kreiranju taska." }) + if current is None and not template_id and not title: + raise serializers.ValidationError({ + 'title': "Naslov zadatka je obavezan ako se task ne kreira iz templatea." + }) + if work_order and not vehicle: vehicle = work_order.vehicle attrs['vehicle'] = vehicle @@ -44,7 +145,7 @@ class TaskSerializer(serializers.ModelSerializer): 'work_order': "Putni nalog mora pripadati istoj dizalici kao task." }) - if status_value == 'neaktivan' and not work_order: + if status_value in {'neaktivan', 'zavrsen'} and not work_order: raise serializers.ValidationError({ 'work_order': "Putni nalog je obavezan prije zatvaranja zadatka." }) @@ -72,4 +173,4 @@ class TaskSerializer(serializers.ModelSerializer): def get_vehicle_registration(self, obj): if not obj.vehicle_id: return None - return obj.vehicle.registration_number \ No newline at end of file + return obj.vehicle.registration_number diff --git a/backend/modules/task_management/services.py b/backend/modules/task_management/services.py index 89d34cf..5339d8f 100644 --- a/backend/modules/task_management/services.py +++ b/backend/modules/task_management/services.py @@ -1,26 +1,45 @@ -# backend/modules/task_management/services.py +# backend/modules/task_management/services.py -from .models import Task +from django.db import transaction from rest_framework.exceptions import ValidationError from django.core.exceptions import ValidationError as DjangoValidationError import logging +from .models import Task, TaskTemplate, TaskWorkEntry +from modules.fleet.models import VehicleServiceRecord + logger = logging.getLogger(__name__) + class TaskService: @staticmethod def create_task_entry(data: dict, user=None) -> Task: """ Univerzalna metoda: prihvaća podatke i opcionalnog korisnika. """ - # Poslovna pravila - if data.get('status') == 'DONE' and (user and not user.is_staff): + payload = dict(data) + template_id = payload.pop('template_id', None) + + if payload.get('status') == 'DONE' and (user and not user.is_staff): raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."}) - payload = dict(data) + template = None + if template_id: + template = TaskTemplate.objects.filter(id=template_id, is_active=True).prefetch_related('entries').first() + if not template: + raise ValidationError({"template_id": "Odabrani template nije pronađen."}) + + payload['title'] = template.title + payload['description'] = template.description + payload['status'] = 'aktivan' + payload.setdefault('work_order', None) + title = (payload.get('title') or '').strip() if title and len(title) < 3: raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."}) + if not title: + raise ValidationError({"title": "Naslov zadatka je obavezan."}) + if user and 'assigned_to' not in payload: payload['assigned_to'] = user @@ -30,18 +49,70 @@ class TaskService: except DjangoValidationError as exc: logger.error(f"Neuspješna validacija zadatka: {exc}") raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages) - entry.save() + + with transaction.atomic(): + entry.save() + if template: + TaskService._create_work_entries_from_template(task=entry, template=template) + TaskService._create_service_records_from_template(task=entry, template=template, user=user) + logger.info(f"Zadatak uspješno kreiran: {entry.id}") return entry + @staticmethod + def _create_work_entries_from_template(*, task: Task, template: TaskTemplate): + template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10]) + for idx, source in enumerate(template_entries, start=1): + TaskWorkEntry.objects.create( + task=task, + template_entry=source, + sort_order=idx, + title=source.title, + work_description=source.work_description, + active_faults=source.active_faults, + explanation_hr=source.explanation_hr, + ) + + @staticmethod + def _create_service_records_from_template(*, task: Task, template: TaskTemplate, user): + if not user: + raise ValidationError({ + "template_id": "Kreiranje taska iz templatea zahtijeva prijavljenog korisnika za servisne zapise." + }) + if not task.vehicle_id: + raise ValidationError({ + "vehicle": "Task iz templatea mora imati dodijeljenu dizalicu za generiranje servisnih zapisa." + }) + + template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10]) + for source in template_entries: + description_parts = [] + if source.work_description: + description_parts.append(source.work_description.strip()) + if source.explanation_hr: + description_parts.append(f"Obrazloženje (HR): {source.explanation_hr.strip()}") + if source.active_faults: + description_parts.append(f"Aktivne greške: {source.active_faults.strip()}") + description = "\n\n".join([part for part in description_parts if part]).strip() or source.title + + VehicleServiceRecord.objects.create( + vehicle=task.vehicle, + task=task, + performed_by=user, + service_title=source.title, + description=description, + parts=(source.active_faults or '').strip(), + cost=0, + ) + @staticmethod def update_task_entry(instance, data, user): - # Validacija za update (npr. samo vlasnik ili admin smije ažurirati) if not user.is_staff and instance.assigned_to != user: raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.") - - # Spremanje + for attr, value in data.items(): + if attr == 'template_id': + continue setattr(instance, attr, value) if 'title' in data and len(str(data.get('title') or '').strip()) < 3: raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."}) @@ -50,4 +121,4 @@ class TaskService: except DjangoValidationError as exc: raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages) instance.save() - return instance \ No newline at end of file + return instance diff --git a/backend/modules/task_management/tests/test_serializers.py b/backend/modules/task_management/tests/test_serializers.py index 546aacb..fdd3819 100644 --- a/backend/modules/task_management/tests/test_serializers.py +++ b/backend/modules/task_management/tests/test_serializers.py @@ -31,4 +31,16 @@ class TaskSerializerTests(TestCase): task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle) serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True) + assert serializer.is_valid(), serializer.errors + + def test_cannot_mark_finished_without_work_order(self): + serializer = TaskSerializer(data={"title": "Task 3", "description": "", "status": "zavrsen", "vehicle": str(self.vehicle.id)}) + with self.assertRaises(ValidationError): + serializer.is_valid(raise_exception=True) + + def test_can_mark_finished_with_work_order(self): + work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='kontrola') + task = Task.objects.create(title="Task 4", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle) + + serializer = TaskSerializer(instance=task, data={"status": "zavrsen", "work_order": str(work_order.id)}, partial=True) assert serializer.is_valid(), serializer.errors \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_services.py b/backend/modules/task_management/tests/test_services.py index 179591f..9049fdc 100644 --- a/backend/modules/task_management/tests/test_services.py +++ b/backend/modules/task_management/tests/test_services.py @@ -2,8 +2,8 @@ from django.test import TestCase from django.contrib.auth import get_user_model from rest_framework.exceptions import ValidationError from modules.task_management.services import TaskService -from modules.task_management.models import Task -from modules.fleet.models import Vehicle, WorkOrder +from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry +from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord class TaskServiceTests(TestCase): @@ -50,4 +50,84 @@ class TaskServiceTests(TestCase): user=self.user, ) assert updated.status == "neaktivan" - assert updated.work_order == work_order \ No newline at end of file + assert updated.work_order == work_order + + def test_update_task_entry_rejects_finished_without_work_order(self): + task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis') + with self.assertRaises(ValidationError): + TaskService.update_task_entry(instance=task, data={"status": "zavrsen"}, user=self.user) + + def test_update_task_entry_allows_finished_with_work_order(self): + vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01A", asset_type='crane') + work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='kontrola') + task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis', vehicle=vehicle) + updated = TaskService.update_task_entry( + instance=task, + data={"status": "zavrsen", "work_order": work_order}, + user=self.user, + ) + assert updated.status == "zavrsen" + assert updated.work_order == work_order + + def test_create_task_entry_from_template_creates_service_records(self): + vehicle = Vehicle.objects.create(registration_number="ZG-TASK-02", asset_type='crane') + template = TaskTemplate.objects.create( + code='template-servis-zapisi', + title='Template servis', + description='Opis templatea', + status='aktivan', + ) + TaskTemplateEntry.objects.create( + task_template=template, + sort_order=1, + title='Pregled sustava', + work_description='Provjera hidraulike', + active_faults='Greška E101', + explanation_hr='Prevedeno i objašnjeno.', + ) + TaskTemplateEntry.objects.create( + task_template=template, + sort_order=2, + title='Zamjena senzora', + work_description='Zamijenjen senzor kuta', + active_faults='', + explanation_hr='Senzor kalibriran.', + ) + + created = TaskService.create_task_entry( + data={'template_id': template.id, 'vehicle': vehicle}, + user=self.user, + ) + + self.assertEqual(created.vehicle_id, vehicle.id) + self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 2) + records = VehicleServiceRecord.objects.filter(task=created).order_by('created_at') + self.assertEqual(records.count(), 2) + self.assertEqual(records.first().service_title, 'Pregled sustava') + self.assertIn('Obrazloženje (HR): Prevedeno i objašnjeno.', records.first().description) + self.assertIn('Aktivne greške: Greška E101', records.first().description) + self.assertEqual(records.first().performed_by_id, self.user.id) + + def test_create_task_entry_from_template_limits_service_records_to_ten(self): + vehicle = Vehicle.objects.create(registration_number="ZG-TASK-03", asset_type='crane') + template = TaskTemplate.objects.create( + code='template-limit-10', + title='Template limit', + description='Opis', + status='aktivan', + ) + for index in range(1, 13): + TaskTemplateEntry.objects.create( + task_template=template, + sort_order=index, + title=f'Korak {index}', + work_description=f'Opis {index}', + ) + + created = TaskService.create_task_entry( + data={'template_id': template.id, 'vehicle': vehicle}, + user=self.user, + ) + + self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 10) + self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10) \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_views.py b/backend/modules/task_management/tests/test_views.py index 8035c5e..6596103 100644 --- a/backend/modules/task_management/tests/test_views.py +++ b/backend/modules/task_management/tests/test_views.py @@ -1,10 +1,11 @@ from django.test import TestCase, RequestFactory from django.contrib.auth import get_user_model from unittest.mock import patch +from rest_framework.test import APIClient from modules.task_management.views import TaskViewSet from modules.task_management.serializers import TaskSerializer -from modules.task_management.models import Task +from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry from modules.fleet.models import Vehicle @@ -17,6 +18,8 @@ class TaskViewSetTests(TestCase): self.view.request = self.factory.post("/") self.view.request.user = self.user self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane') + self.api_client = APIClient() + self.api_client.force_authenticate(user=self.user) @patch("modules.task_management.views.TaskService.create_task_entry") def test_perform_create_calls_service(self, mock_create): @@ -53,4 +56,32 @@ class TaskViewSetTests(TestCase): task = Task.objects.create(title="ToDelete", description="", assigned_to=self.user) self.view.perform_destroy(task) task.refresh_from_db() - assert task.is_active is False \ No newline at end of file + assert task.is_active is False + + def test_template_created_task_is_visible_in_service_records_endpoint(self): + template = TaskTemplate.objects.create( + code='template-api-service-records', + title='Template API', + description='Opis', + status='aktivan', + ) + TaskTemplateEntry.objects.create( + task_template=template, + sort_order=1, + title='Korak API', + work_description='Opis API koraka', + ) + + response = self.api_client.post( + "/api/tasks/tasks/", + data={"template_id": str(template.id), "vehicle": str(self.vehicle.id)}, + format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + created_task_id = str(response.json()["id"]) + + service_records_response = self.api_client.get("/api/fleet/service-records/") + self.assertEqual(service_records_response.status_code, 200, service_records_response.content) + payload = service_records_response.json() + rows = payload if isinstance(payload, list) else payload.get("results", []) + self.assertTrue(any(str(row.get("task") or "") == created_task_id for row in rows)) \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_work_hours_table.py b/backend/modules/task_management/tests/test_work_hours_table.py new file mode 100644 index 0000000..8fbeb57 --- /dev/null +++ b/backend/modules/task_management/tests/test_work_hours_table.py @@ -0,0 +1,97 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord +from modules.task_management.models import Task, TaskWorkHoursTable + + +class TaskWorkHoursTableTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="wh-user", email="wh-user@example.test", password="Pass123!abc") + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.vehicle = Vehicle.objects.create(registration_number="ZG-WH-01", asset_type='crane') + self.work_order = WorkOrder.objects.create( + vehicle=self.vehicle, + creator=self.user, + purpose='kontrola', + location='Zagreb', + ) + self.task = Task.objects.create( + title="Task tablica sati", + description="", + status="aktivan", + assigned_to=self.user, + vehicle=self.vehicle, + work_order=self.work_order, + ) + + def test_put_work_hours_table_accepts_null_data(self): + response = self.client.put( + f"/api/tasks/tasks/{self.task.id}/work-hours-table/", + data=None, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertIsNone(response.json().get('data')) + + def test_put_work_hours_table_accepts_partial_row_and_normalizes(self): + response = self.client.put( + f"/api/tasks/tasks/{self.task.id}/work-hours-table/", + data={"rows": [{"day": "NED", "travel_hours": "2,5", "departure_place": "Zagreb"}]}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + payload = response.json() + rows = payload.get('data', {}).get('rows', []) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].get('day'), 'NED') + self.assertEqual(rows[0].get('travel_hours'), '2,5') + self.assertEqual(rows[0].get('departure_place'), 'Zagreb') + self.assertIn('arrival_place', rows[0]) + self.assertEqual(rows[0].get('arrival_place'), '') + + def test_put_work_hours_table_rejects_invalid_json_shape(self): + response = self.client.put( + f"/api/tasks/tasks/{self.task.id}/work-hours-table/", + data="invalid-shape", + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + + def test_task_service_context_includes_work_hours_table_data(self): + TaskWorkHoursTable.objects.create( + task=self.task, + data={"rows": [{"day": "NED", "date": "12.07.2026"}]}, + ) + VehicleServiceRecord.objects.create( + vehicle=self.vehicle, + task=self.task, + performed_by=self.user, + service_title="Servis zapis", + description="Opis", + cost=0, + ) + + response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/task-service-context/") + self.assertEqual(response.status_code, 200, response.content) + payload = response.json() + tasks = payload.get('tasks', []) + self.assertEqual(len(tasks), 1) + self.assertEqual(tasks[0].get('work_hours_table'), {"rows": [{"day": "NED", "date": "12.07.2026"}]}) + + def test_service_records_pdf_works_with_empty_work_hours_table(self): + VehicleServiceRecord.objects.create( + vehicle=self.vehicle, + task=self.task, + performed_by=self.user, + service_title="Servis zapis", + description="Opis", + cost=0, + ) + + response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/service-records-pdf/") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response["Content-Type"], "application/pdf") diff --git a/backend/modules/task_management/views.py b/backend/modules/task_management/views.py index 3c402d0..6dedad7 100644 --- a/backend/modules/task_management/views.py +++ b/backend/modules/task_management/views.py @@ -1,15 +1,18 @@ -# backend/modules/task_management/views.py +# backend/modules/task_management/views.py import logging from rest_framework import viewsets, permissions +from rest_framework.decorators import action +from rest_framework.response import Response from rest_framework.exceptions import ValidationError -from .models import Task -from .serializers import TaskSerializer + +from .models import Task, TaskTemplate +from .serializers import TaskSerializer, TaskTemplateSerializer, TaskWorkHoursTableSerializer from .services import TaskService -# Postavljanje loggera za modul logger = logging.getLogger(__name__) + class TaskViewSet(viewsets.ModelViewSet): """ ViewSet koji koristi TaskService za rukovanje operacijama. @@ -19,7 +22,7 @@ class TaskViewSet(viewsets.ModelViewSet): def get_queryset(self): qs = Task.objects.filter(is_active=True).select_related( - 'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle' + 'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle', 'work_hours_table' ) vehicle_id = self.request.query_params.get('vehicle') if vehicle_id: @@ -27,38 +30,60 @@ class TaskViewSet(viewsets.ModelViewSet): return qs def perform_create(self, serializer): - """ - Umjesto direktnog serializer.save(), pozivamo servis. - Proslijeđujemo validirane podatke (dict) i korisnika. - """ try: entry = TaskService.create_task_entry( data=serializer.validated_data, - user=self.request.user) + user=self.request.user, + ) serializer.instance = entry except ValidationError as e: - # Ponovno podižemo iznimku kako bi DRF ispravno vratio 400 Bad Request logger.warning(f"Validacijska greška pri kreiranju zadatka od strane {self.request.user}: {e}") raise e def perform_update(self, serializer): - """ - Servis obavlja biznis logiku (validaciju/modifikaciju), - a serializer.save() finalizira zapis. - """ - # Servis sada samo modificira instancu u memoriji TaskService.update_task_entry( instance=serializer.instance, data=serializer.validated_data, - user=self.request.user + user=self.request.user, ) - # Sada DRF sigurno sprema izmijenjenu instancu serializer.save() - + def perform_destroy(self, instance): - """ - Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno. - """ instance.is_active = False instance.save() logger.info(f"Zapis zadatka {instance.id} je deaktiviran od strane {self.request.user}") + + @action(detail=False, methods=['get'], url_path='templates') + def templates(self, request): + templates = TaskTemplate.objects.filter(is_active=True).prefetch_related('entries').order_by('title') + serializer = TaskTemplateSerializer(templates, many=True) + return Response(serializer.data) + + @action(detail=True, methods=['get', 'put', 'patch'], url_path='work-hours-table') + def work_hours_table(self, request, pk=None): + task = self.get_object() + table = getattr(task, 'work_hours_table', None) + + if request.method.lower() == 'get': + if table: + serializer = TaskWorkHoursTableSerializer(table) + return Response(serializer.data) + return Response({'task': str(task.id), 'data': None}) + + if isinstance(request.data, dict) and 'data' in request.data: + payload_data = request.data.get('data') + else: + payload_data = request.data + if payload_data == {}: + payload_data = None + serializer = TaskWorkHoursTableSerializer( + table, + data={ + 'task': str(task.id), + 'data': payload_data, + }, + partial=bool(table), + ) + serializer.is_valid(raise_exception=True) + instance = serializer.save(task=task) + return Response(TaskWorkHoursTableSerializer(instance).data) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bf42237..aaba597 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,7 +5,7 @@ services: context: ./backend dockerfile: Dockerfile command: python manage.py runserver 0.0.0.0:8000 - user: "1001:1001" + user: "1000:1000" volumes: - ./backend:/app - media_volume:/app/media @@ -30,13 +30,11 @@ services: - /app/node_modules environment: - NODE_ENV=development - - PUBLIC_ALLOWED_HOSTS=serviseri-004.captain.mitteworkspace.cloud - - PUBLIC_HMR_HOST=serviseri-004.captain.mitteworkspace.cloud + - PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1 + - PUBLIC_HMR_HOST=localhost - PUBLIC_HMR_PROTOCOL=ws - PUBLIC_HMR_CLIENT_PORT=4321 - - PUBLIC_HMR_PROTOCOL=wss - - PUBLIC_HMR_CLIENT_PORT=443 - - PUBLIC_API_URL=https://api-004.captain.mitteworkspace.cloud/api/ + - PUBLIC_API_URL=http://localhost:8001/api/ flower: container_name: 004erpflower diff --git a/frontend/src/components/dashboard/TaskCreateModal.jsx b/frontend/src/components/dashboard/TaskCreateModal.jsx index 4ceef80..b31b6e1 100644 --- a/frontend/src/components/dashboard/TaskCreateModal.jsx +++ b/frontend/src/components/dashboard/TaskCreateModal.jsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from 'preact/hooks'; -import { createTask, getStatusLabel } from '../../stores/taskStore'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore'; import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds'; +import ModalShell from '../ui/ModalShell'; -const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan']; +const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan']; const INITIAL_FORM = { title: '', @@ -15,48 +16,107 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = const [form, setForm] = useState(INITIAL_FORM); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); + const [templateMode, setTemplateMode] = useState(false); + const [templateLoading, setTemplateLoading] = useState(false); + const [templateId, setTemplateId] = useState(''); + const [templates, setTemplates] = useState([]); + + const selectedTemplate = useMemo( + () => templates.find((template) => String(template.id) === String(templateId)) || null, + [templates, templateId] + ); useEffect(() => { if (!open) { setForm(INITIAL_FORM); setSubmitting(false); setError(''); + setTemplateMode(false); + setTemplateId(''); + setTemplates([]); + setTemplateLoading(false); } }, [open]); + useEffect(() => { + if (!selectedTemplate) return; + setForm((prev) => ({ + ...prev, + title: selectedTemplate.title || '', + description: selectedTemplate.description || '', + status: 'aktivan', + work_order: '', + })); + }, [selectedTemplate]); + if (!open) return null; function setField(name, value) { setForm((prev) => ({ ...prev, [name]: value })); } + async function enableTemplateMode() { + if (templateMode) { + setTemplateMode(false); + setTemplateId(''); + setForm(INITIAL_FORM); + return; + } + setError(''); + setTemplateLoading(true); + try { + const fetched = await fetchTaskTemplates(); + setTemplates(fetched); + setTemplateMode(true); + } catch (err) { + setError(err?.message || 'Neuspješno učitavanje templatea.'); + } finally { + setTemplateLoading(false); + } + } + async function submit(event) { event.preventDefault(); setError(''); - const title = String(form.title || '').trim(); - if (!title) { - setError('Naslov zadatka je obavezan.'); - return; - } - if (form.status === 'neaktivan' && !form.work_order) { - setError('Putni nalog je obavezan prije zatvaranja zadatka.'); - return; - } if (!contextCrane?.id) { setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.'); return; } - setSubmitting(true); - try { - await createTask({ + let payload; + if (templateMode) { + if (!templateId) { + setError('Odaberite template za unos zadatka.'); + return; + } + payload = { + template_id: templateId, + vehicle: contextCrane.id, + work_order: null, + }; + } else { + const title = String(form.title || '').trim(); + if (!title) { + setError('Naslov zadatka je obavezan.'); + return; + } + if ((form.status === 'neaktivan' || form.status === 'zavrsen') && !form.work_order) { + setError('Putni nalog je obavezan prije zatvaranja zadatka.'); + return; + } + payload = { title, description: String(form.description || '').trim(), status: form.status || 'aktivan', vehicle: contextCrane.id, work_order: form.work_order || null, - }); + }; + } + + setSubmitting(true); + try { + await createTask(payload); await onSuccess?.(); onClose?.(); } catch (err) { @@ -67,15 +127,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = } return ( -
-
-
event.stopPropagation()} - > +

Novi radni zadatak

@@ -83,26 +141,60 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}

- +
+ + +
+ {templateMode && ( + + )} + @@ -112,7 +204,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = rows={3} value={form.description} onInput={(event) => setField('description', event.currentTarget.value)} - disabled={submitting} + disabled={submitting || templateMode} className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50" /> @@ -123,7 +215,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = setField('work_order', event.currentTarget.value)} - disabled={submitting} + disabled={submitting || templateMode} className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50" > @@ -149,6 +241,11 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = ))} + {templateMode && ( + + Kod template unosa nalog nije obavezan i backend ga neće automatski povezati. + + )} {workOrders.length === 0 && ( Nema dostupnih naloga za odabranu dizalicu. @@ -175,14 +272,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
-
-
+ ); } diff --git a/frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx b/frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx new file mode 100644 index 0000000..983656d --- /dev/null +++ b/frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx @@ -0,0 +1,476 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import ModalShell from '../ui/ModalShell'; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function parseTime(str) { + if (!str || typeof str !== 'string') return null; + const match = str.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + const h = parseInt(match[1], 10); + const m = parseInt(match[2], 10); + if (h > 23 || m > 59) return null; + return h + m / 60; +} + +function calcHours(fromStr, toStr) { + const from = parseTime(fromStr); + const to = parseTime(toStr); + if (from === null || to === null) return null; + let diff = to - from; + if (diff < 0) diff += 24; // overnight + return Math.max(0, diff); +} + +function parseNum(value) { + if (value == null || value === '') return 0; + const n = Number(String(value).replace(',', '.')); + return Number.isFinite(n) ? n : 0; +} + +function fmtH(h) { return h.toFixed(1).replace('.', ','); } + +/** Converts decimal hours back to "HH:MM" string. */ +function formatTime(decimalHours) { + if (!Number.isFinite(decimalHours)) return ''; + let h = ((decimalHours % 24) + 24) % 24; + const hours = Math.floor(h); + let mins = Math.round((h - hours) * 60); + if (mins === 60) return `${String(hours + 1).padStart(2, '0')}:00`; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +/** Returns 'travel' | 'work' | 'empty' for a given row. */ +function rowMode(row) { + if (row?.travel_time_from?.trim() || row?.travel_time_to?.trim()) return 'travel'; + if (row?.work_time_from?.trim() || row?.work_time_to?.trim()) return 'work'; + return 'empty'; +} + +/** + * Applies mutual-exclusion and auto-calculation to a single row. + * Travel mode → clears work fields, computes travel_hours from times − break. + * Work mode → clears travel fields + vehicle_km, computes work_hours. + * Empty mode → clears both calculated totals. + */ +function computeRow(row) { + const r = { ...row }; + const mode = rowMode(r); + + if (mode === 'travel') { + r.work_time_from = ''; + r.work_time_to = ''; + r.work_hours = ''; + const raw = calcHours(r.travel_time_from, r.travel_time_to); + r.travel_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : ''; + } else if (mode === 'work') { + r.travel_time_from = ''; + r.travel_time_to = ''; + r.travel_hours = ''; + r.vehicle_km = ''; + const raw = calcHours(r.work_time_from, r.work_time_to); + r.work_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : ''; + } else { + r.work_hours = ''; + r.travel_hours = ''; + } + return r; +} + +const INTERNAL = ['_groupId', '_role']; +function stripInternal(row) { const r = { ...row }; for (const f of INTERNAL) delete r[f]; return r; } + +let _gidSeq = 0; +function newGroupId() { _gidSeq += 1; return `g${_gidSeq}`; } + +// ─── constants ─────────────────────────────────────────────────────────────── + +const EMPTY_ROW = { + day: '', date: '', + work_time_from: '', work_time_to: '', + travel_time_from: '', travel_time_to: '', + break_hours: '', + work_hours: '', travel_hours: '', + departure_place: '', arrival_place: '', + vehicle_km: '', +}; + +/** + * computed: true → value is auto-calculated, never editable directly. + * travelOnly: true → disabled when mode === 'work'. + * workOnly: true → disabled when mode === 'travel'. + */ +const FIELD_CONFIGS = [ + { key: 'day', label: 'Dan', type: 'select' }, + { key: 'date', label: 'Datum', type: 'text', placeholder: 'dd.mm.gggg' }, + { key: 'work_time_from', label: 'Vr. rada od', type: 'time', workOnly: true }, + { key: 'work_time_to', label: 'Vr. rada do', type: 'time', workOnly: true }, + { key: 'travel_time_from', label: 'Put od', type: 'time', travelOnly: true }, + { key: 'travel_time_to', label: 'Put do', type: 'time', travelOnly: true }, + { key: 'break_hours', label: 'Pauza h', type: 'number', step: '0.5', min: '0', placeholder: '0.0' }, + { key: 'work_hours', label: 'Sati rada', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true }, + { key: 'travel_hours', label: 'Sati puta', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true }, + { key: 'departure_place', label: 'Mj. polaska', type: 'text', placeholder: 'Zagreb', travelOnly: true }, + { key: 'arrival_place', label: 'Mj. dolaska', type: 'text', placeholder: 'npr. Split', travelOnly: true }, + { key: 'vehicle_km', label: 'Km vozila', type: 'number', step: '1', min: '0', placeholder: '0', travelOnly: true }, +]; + +const DAY_OPTIONS = ['', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB', 'NED']; + +const FB_INPUT = [ + 'block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs', + 'text-gray-900 placeholder-gray-400', + 'focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none', + 'dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400', + 'dark:focus:border-blue-500 dark:focus:ring-blue-500', + 'transition-colors duration-150', +].join(' '); + +const FB_MUTED = [ + 'block w-full rounded-lg border border-gray-200 bg-gray-100 px-2.5 py-2 text-xs', + 'text-gray-400 cursor-default select-none', + 'dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500', +].join(' '); + +// ─── component ─────────────────────────────────────────────────────────────── + +export default function TaskWorkHoursTableModal({ + open, + task, + defaultLocation = '', + saving = false, + onClose, + onSave, +}) { + const [rows, setRows] = useState([]); + + useEffect(() => { + if (!open) { setRows([]); return; } + const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : []; + setRows( + incoming.length > 0 + ? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) })) + : [{ ...EMPTY_ROW }, { ...EMPTY_ROW }, { ...EMPTY_ROW }], + ); + }, [open, task?.id, task?.work_hours_table]); + + if (!open || !task) return null; + + // ── derived totals ───────────────────────────────────────────────────── + const totals = useMemo(() => { + const travel = rows.reduce((s, r) => s + parseNum(r.travel_hours), 0); + const work = rows.reduce((s, r) => s + parseNum(r.work_hours), 0); + const km = rows.reduce((s, r) => s + parseNum(r.vehicle_km), 0); + return { + travel: fmtH(travel), + work: fmtH(work), + km: Number.isInteger(km) ? String(km) : km.toFixed(1), + }; + }, [rows]); + + // ── row mutation ─────────────────────────────────────────────────────── + const updateRowField = (index, field, value) => { + setRows((prev) => { + // Step 1: update the changed row with full mutual-exclusion + calc + let updated = prev.map((row, i) => { + if (i !== index) return row; + return computeRow({ ...row, [field]: value }); + }); + + const changed = updated[index]; + const gid = changed?._groupId; + const role = changed?._role; + if (!gid) return updated; + + // Step 2: departure row changes → sync return row + if (role === 'departure') { + updated = updated.map((row, i) => { + if (i === index || row._groupId !== gid || row._role !== 'return') return row; + + let returnRow = { ...row }; + + // Mirror day/date always + if (field === 'day' || field === 'date') { + returnRow[field] = value; + } + + // Mirror break_hours + if (field === 'break_hours') { + returnRow.break_hours = value; + } + + // Mirror vehicle_km + if (field === 'vehicle_km') { + returnRow.vehicle_km = value; + } + + // Mirror travel times + recalculate return travel_to from duration + if (field === 'travel_time_from' || field === 'travel_time_to') { + returnRow.travel_time_from = changed.travel_time_from; + // Recalculate return's travel_to: return departs when work ends (travel_time_from stays) + // and duration equals departure trip duration + const depDuration = calcHours(changed.travel_time_from, changed.travel_time_to); + if (depDuration !== null && returnRow.travel_time_from) { + const retFrom = parseTime(returnRow.travel_time_from); + if (retFrom !== null) returnRow.travel_time_to = formatTime(retFrom + depDuration); + } + } + + // Mirror places (swapped): arrival of departure → departure of return + if (field === 'arrival_place') { + returnRow.departure_place = value; + } + if (field === 'departure_place') { + returnRow.arrival_place = value; + } + + return computeRow(returnRow); + }); + } + + // Step 3: work row's work_time_to → fill return row's travel_time_from + // and recalculate return row's travel_time_to using departure duration + if (role === 'work' && field === 'work_time_to') { + const depRow = updated.find(r => r._groupId === gid && r._role === 'departure'); + const depDuration = depRow ? calcHours(depRow.travel_time_from, depRow.travel_time_to) : null; + + updated = updated.map((row, i) => { + if (i === index || row._groupId !== gid || row._role !== 'return') return row; + const newFrom = value; // Put od = when work ends + const newTo = (depDuration !== null && parseTime(newFrom) !== null) + ? formatTime(parseTime(newFrom) + depDuration) + : row.travel_time_to; + return computeRow({ ...row, travel_time_from: newFrom, travel_time_to: newTo }); + }); + } + + return updated; + }); + }; + + const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]); + + const addThreeRows = () => { + const today = new Date(); + const dd = String(today.getDate()).padStart(2, '0'); + const mm = String(today.getMonth() + 1).padStart(2, '0'); + const dateStr = `${dd}.${mm}.${today.getFullYear()}`; + const dayLabels = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB']; + const dayLabel = dayLabels[today.getDay()]; + const gid = newGroupId(); + const loc = defaultLocation || ''; + + setRows((prev) => [ + ...prev, + { ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' }, + { ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' }, + { ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' }, + ]); + }; + + const removeRow = (i) => setRows((prev) => prev.filter((_, idx) => idx !== i)); + + const handleSave = async () => { + await onSave?.({ rows: rows.map(stripInternal) }); + }; + + // ── field rendering ──────────────────────────────────────────────────── + const isDisabled = (row, f) => { + const mode = rowMode(row); + if (f.computed) return true; + if (f.travelOnly && mode === 'work') return true; + if (f.workOnly && mode === 'travel') return true; + return false; + }; + + const renderCell = (row, idx, f) => { + const disabled = isDisabled(row, f); + const cls = disabled ? FB_MUTED : FB_INPUT; + + if (f.type === 'select') { + return ( + + ); + } + + return ( + !disabled && updateRowField(idx, f.key, e.currentTarget.value)} + readOnly={f.computed} + disabled={disabled} + className={cls} + placeholder={f.placeholder || ''} + step={f.step} + min={f.min} + /> + ); + }; + + // ── render ───────────────────────────────────────────────────────────── + return ( + +
+ + {/* ── header ── */} +
+
+

Tablica radnih sati

+

{task.title || '-'}

+
+ +
+ +
+ + {/* ── info banner ── */} +
+ Put (od–do): unesi Put od/do → Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana. + Rad (od–do): unesi Vr. rada od/do → Sati rada se računaju automatski; Km, Put, Mj. polaska/dolaska su blokirani. + Pauza umanjuje izračunate sate u oba slučaja. +
+ + {/* ── toolbar ── */} +
+ + +
+ + {/* ── mobile cards (< xl) ── */} +
+ {rows.length === 0 && ( +

Nema unesenih redaka.

+ )} + {rows.map((row, idx) => ( +
+
+ #{idx + 1} + +
+
+ {FIELD_CONFIGS.map((f) => ( + + ))} +
+
+ ))} + {rows.length > 0 && ( +
+ UKUPNO — + Sati rada: {totals.work} h + · + Sati puta: {totals.travel} h + · + Km vozila: {totals.km} km +
+ )} +
+ + {/* ── desktop table (≥ xl) ── */} +
+
+ + + + {FIELD_CONFIGS.map((f) => ( + + ))} + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((row, idx) => ( + + + {FIELD_CONFIGS.map((f) => ( + + ))} + + + ))} + {rows.length > 0 && ( + /* cols: # day date work_from work_to travel_from travel_to break → 8 cols + then: work_hours travel_hours departure arrival vehicle_km action */ + + + + + + + )} + +
# + {f.label} + {f.computed && (auto)} + Akcija
+ Nema unesenih redaka. +
{idx + 1} + {renderCell(row, idx, f)} + + +
UKUPNO + Sati rada:{totals.work} h + + Sati puta:{totals.travel} h + + + Km:{totals.km} + +
+
+
+ +
+ + {/* ── footer ── */} +
+ + +
+ +
+
+ ); +} diff --git a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx index 4bbb4af..fe77bf2 100644 --- a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx +++ b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx @@ -7,11 +7,13 @@ import { fetchWorkOrderById, fetchWorkOrderInvoices, fetchWorkOrderTaskServiceContext, + updateTaskWorkHoursTable, } from '../../stores/fleetDashboardStore'; import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore'; import { fetchNotifications, connectNotifications } from '../../stores/notificationStore'; import { formatEntityCode } from '../../lib/displayIds'; import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel'; +import TaskWorkHoursTableModal from './TaskWorkHoursTableModal'; function readWorkOrderIdFromQuery() { if (typeof window === 'undefined') { @@ -53,6 +55,8 @@ export default function WorkOrderInvoicesPdfPage() { const [taskContext, setTaskContext] = useState({ tasks: [] }); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [editingTask, setEditingTask] = useState(null); + const [savingWorkHours, setSavingWorkHours] = useState(false); useEffect(() => { hydrateAuthFromStorage(); @@ -149,6 +153,25 @@ export default function WorkOrderInvoicesPdfPage() { const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(mediaPaths); + const handleSaveWorkHoursTable = async (tableData) => { + if (!editingTask?.id) return; + setSavingWorkHours(true); + try { + const response = await updateTaskWorkHoursTable(editingTask.id, tableData); + setTaskContext((prev) => ({ + ...prev, + tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => ( + String(task.id) === String(editingTask.id) + ? { ...task, work_hours_table: response?.data ?? null } + : task + )), + })); + setEditingTask(null); + } finally { + setSavingWorkHours(false); + } + }; + return (
@@ -196,6 +219,15 @@ export default function WorkOrderInvoicesPdfPage() {
Dodijeljeno: {task.assigned_to_name || '-'}
Dizalica: {task.vehicle_registration || '-'}
+
+ +
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (

Nema povezanih servisnih zapisa.

@@ -281,6 +313,14 @@ export default function WorkOrderInvoicesPdfPage() {
)} + setEditingTask(null)} + onSave={handleSaveWorkHoursTable} + />
); } diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index b805caa..a651a0a 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -692,6 +692,15 @@ export async function fetchWorkOrderTaskServiceContext(workOrderId) { return payload && typeof payload === 'object' ? payload : { tasks: [] }; } +export async function updateTaskWorkHoursTable(taskId, data) { + if (!taskId) { + throw new Error('Task ID je obavezan.'); + } + const payload = await api.put(`tasks/tasks/${encodeURIComponent(taskId)}/work-hours-table/`, data ?? null); + showToast('Tablica radnih sati je uspješno spremljena.', 'success'); + return payload; +} + export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); diff --git a/frontend/src/stores/taskStore.js b/frontend/src/stores/taskStore.js index 8bbe41c..fbf7e48 100644 --- a/frontend/src/stores/taskStore.js +++ b/frontend/src/stores/taskStore.js @@ -1,14 +1,17 @@ -// src/stores/taskStore.js +// src/stores/taskStore.js import { atom } from 'nanostores'; import { api } from '../services/apiClient'; import { showToast } from './toastStore'; export const $tasks = atom([]); export const $tasksLoading = atom(false); +export const $taskTemplates = atom([]); +export const $taskTemplatesLoading = atom(false); const STATUS_LABELS = { aktivan: 'Aktivan', servis: 'U tijeku', + zavrsen: 'Završen', neaktivan: 'Otkazano', }; @@ -35,6 +38,23 @@ export async function fetchTasks() { } } +export async function fetchTaskTemplates() { + $taskTemplatesLoading.set(true); + try { + const data = await api.get('tasks/tasks/templates/'); + const templates = Array.isArray(data) ? data : (data?.results ?? []); + $taskTemplates.set(templates); + return templates; + } catch (error) { + if (error?.status !== 401) { + showToast('Greška pri dohvatu predložaka zadataka.', 'error'); + } + throw error; + } finally { + $taskTemplatesLoading.set(false); + } +} + export async function createTask(data) { const response = await api.post('tasks/tasks/', data); $tasks.set([response, ...$tasks.get()]);