interaktivna Tablica radnih sati, db
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

This commit is contained in:
mariomitte
2026-07-13 00:36:28 +02:00
parent 70151f1225
commit 3ad536a23e
22 changed files with 1629 additions and 174 deletions

View File

@@ -46,4 +46,14 @@ INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
INTERNAL_IPS = ['127.0.0.1']
CELERY_TASK_ALWAYS_EAGER = True
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'

View File

@@ -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'

View File

@@ -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,
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
})
})
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}"

View File

@@ -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
return obj.vehicle.registration_number

View File

@@ -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
return instance

View File

@@ -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

View File

@@ -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
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)

View File

@@ -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
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))

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'preact/hooks';
import { createTask, getStatusLabel } from '../../stores/taskStore';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
import ModalShell from '../ui/ModalShell';
const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
const INITIAL_FORM = {
title: '',
@@ -15,48 +16,107 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
const [form, setForm] = useState(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [templateMode, setTemplateMode] = useState(false);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateId, setTemplateId] = useState('');
const [templates, setTemplates] = useState([]);
const selectedTemplate = useMemo(
() => templates.find((template) => String(template.id) === String(templateId)) || null,
[templates, templateId]
);
useEffect(() => {
if (!open) {
setForm(INITIAL_FORM);
setSubmitting(false);
setError('');
setTemplateMode(false);
setTemplateId('');
setTemplates([]);
setTemplateLoading(false);
}
}, [open]);
useEffect(() => {
if (!selectedTemplate) return;
setForm((prev) => ({
...prev,
title: selectedTemplate.title || '',
description: selectedTemplate.description || '',
status: 'aktivan',
work_order: '',
}));
}, [selectedTemplate]);
if (!open) return null;
function setField(name, value) {
setForm((prev) => ({ ...prev, [name]: value }));
}
async function enableTemplateMode() {
if (templateMode) {
setTemplateMode(false);
setTemplateId('');
setForm(INITIAL_FORM);
return;
}
setError('');
setTemplateLoading(true);
try {
const fetched = await fetchTaskTemplates();
setTemplates(fetched);
setTemplateMode(true);
} catch (err) {
setError(err?.message || 'Neuspješno učitavanje templatea.');
} finally {
setTemplateLoading(false);
}
}
async function submit(event) {
event.preventDefault();
setError('');
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if (form.status === 'neaktivan' && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
if (!contextCrane?.id) {
setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
return;
}
setSubmitting(true);
try {
await createTask({
let payload;
if (templateMode) {
if (!templateId) {
setError('Odaberite template za unos zadatka.');
return;
}
payload = {
template_id: templateId,
vehicle: contextCrane.id,
work_order: null,
};
} else {
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if ((form.status === 'neaktivan' || form.status === 'zavrsen') && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
payload = {
title,
description: String(form.description || '').trim(),
status: form.status || 'aktivan',
vehicle: contextCrane.id,
work_order: form.work_order || null,
});
};
}
setSubmitting(true);
try {
await createTask(payload);
await onSuccess?.();
onClose?.();
} catch (err) {
@@ -67,15 +127,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
}
return (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
onClick={onClose}
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
>
<div className="flex min-h-full items-start justify-center">
<div
className="w-full max-w-xl max-h-[calc(100vh-2rem)] overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="w-full flex flex-col">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Novi radni zadatak</h3>
@@ -83,26 +141,60 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={enableTemplateMode}
disabled={templateLoading || submitting}
className="rounded-md border border-border-hairline px-3 py-1 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
{templateMode ? 'Ručni unos' : 'Unos iz templatea'}
</button>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
</div>
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
{templateMode && (
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Template zadatka *</span>
<select
value={templateId}
onChange={(event) => setTemplateId(event.currentTarget.value)}
disabled={submitting || templateLoading}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Odaberite template </option>
{templates.map((template) => (
<option key={template.id} value={String(template.id)}>
{template.title}
</option>
))}
</select>
{selectedTemplate && (
<span className="text-[11px] text-text-muted">
Generirat će se do 10 servisnih zapisa iz predloška (vidljivo u tablici Servisni zapisi).
</span>
)}
</label>
)}
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naslov zadatka *</span>
<input
type="text"
value={form.title}
onInput={(event) => setField('title', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
required
required={!templateMode}
/>
</label>
@@ -112,7 +204,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
rows={3}
value={form.description}
onInput={(event) => setField('description', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
@@ -123,7 +215,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<select
value={form.status}
onChange={(event) => setField('status', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
{STATUS_OPTIONS.map((status) => (
@@ -139,7 +231,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<select
value={form.work_order}
onChange={(event) => setField('work_order', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Bez povezanog naloga </option>
@@ -149,6 +241,11 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
</option>
))}
</select>
{templateMode && (
<span className="text-[11px] text-text-muted">
Kod template unosa nalog nije obavezan i backend ga neće automatski povezati.
</span>
)}
{workOrders.length === 0 && (
<span className="text-[11px] text-amber-600">
Nema dostupnih naloga za odabranu dizalicu.
@@ -175,14 +272,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
disabled={submitting}
disabled={submitting || templateLoading}
>
{submitting ? 'Spremanje...' : 'Kreiraj radni zadatak'}
</button>
</div>
</form>
</div>
</div>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,476 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
// ─── helpers ────────────────────────────────────────────────────────────────
function parseTime(str) {
if (!str || typeof str !== 'string') return null;
const match = str.trim().match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const h = parseInt(match[1], 10);
const m = parseInt(match[2], 10);
if (h > 23 || m > 59) return null;
return h + m / 60;
}
function calcHours(fromStr, toStr) {
const from = parseTime(fromStr);
const to = parseTime(toStr);
if (from === null || to === null) return null;
let diff = to - from;
if (diff < 0) diff += 24; // overnight
return Math.max(0, diff);
}
function parseNum(value) {
if (value == null || value === '') return 0;
const n = Number(String(value).replace(',', '.'));
return Number.isFinite(n) ? n : 0;
}
function fmtH(h) { return h.toFixed(1).replace('.', ','); }
/** Converts decimal hours back to "HH:MM" string. */
function formatTime(decimalHours) {
if (!Number.isFinite(decimalHours)) return '';
let h = ((decimalHours % 24) + 24) % 24;
const hours = Math.floor(h);
let mins = Math.round((h - hours) * 60);
if (mins === 60) return `${String(hours + 1).padStart(2, '0')}:00`;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/** Returns 'travel' | 'work' | 'empty' for a given row. */
function rowMode(row) {
if (row?.travel_time_from?.trim() || row?.travel_time_to?.trim()) return 'travel';
if (row?.work_time_from?.trim() || row?.work_time_to?.trim()) return 'work';
return 'empty';
}
/**
* Applies mutual-exclusion and auto-calculation to a single row.
* Travel mode → clears work fields, computes travel_hours from times break.
* Work mode → clears travel fields + vehicle_km, computes work_hours.
* Empty mode → clears both calculated totals.
*/
function computeRow(row) {
const r = { ...row };
const mode = rowMode(r);
if (mode === 'travel') {
r.work_time_from = '';
r.work_time_to = '';
r.work_hours = '';
const raw = calcHours(r.travel_time_from, r.travel_time_to);
r.travel_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
} else if (mode === 'work') {
r.travel_time_from = '';
r.travel_time_to = '';
r.travel_hours = '';
r.vehicle_km = '';
const raw = calcHours(r.work_time_from, r.work_time_to);
r.work_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
} else {
r.work_hours = '';
r.travel_hours = '';
}
return r;
}
const INTERNAL = ['_groupId', '_role'];
function stripInternal(row) { const r = { ...row }; for (const f of INTERNAL) delete r[f]; return r; }
let _gidSeq = 0;
function newGroupId() { _gidSeq += 1; return `g${_gidSeq}`; }
// ─── constants ───────────────────────────────────────────────────────────────
const EMPTY_ROW = {
day: '', date: '',
work_time_from: '', work_time_to: '',
travel_time_from: '', travel_time_to: '',
break_hours: '',
work_hours: '', travel_hours: '',
departure_place: '', arrival_place: '',
vehicle_km: '',
};
/**
* computed: true → value is auto-calculated, never editable directly.
* travelOnly: true → disabled when mode === 'work'.
* workOnly: true → disabled when mode === 'travel'.
*/
const FIELD_CONFIGS = [
{ key: 'day', label: 'Dan', type: 'select' },
{ key: 'date', label: 'Datum', type: 'text', placeholder: 'dd.mm.gggg' },
{ key: 'work_time_from', label: 'Vr. rada od', type: 'time', workOnly: true },
{ key: 'work_time_to', label: 'Vr. rada do', type: 'time', workOnly: true },
{ key: 'travel_time_from', label: 'Put od', type: 'time', travelOnly: true },
{ key: 'travel_time_to', label: 'Put do', type: 'time', travelOnly: true },
{ key: 'break_hours', label: 'Pauza h', type: 'number', step: '0.5', min: '0', placeholder: '0.0' },
{ key: 'work_hours', label: 'Sati rada', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
{ key: 'travel_hours', label: 'Sati puta', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
{ key: 'departure_place', label: 'Mj. polaska', type: 'text', placeholder: 'Zagreb', travelOnly: true },
{ key: 'arrival_place', label: 'Mj. dolaska', type: 'text', placeholder: 'npr. Split', travelOnly: true },
{ key: 'vehicle_km', label: 'Km vozila', type: 'number', step: '1', min: '0', placeholder: '0', travelOnly: true },
];
const DAY_OPTIONS = ['', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB', 'NED'];
const FB_INPUT = [
'block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs',
'text-gray-900 placeholder-gray-400',
'focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none',
'dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400',
'dark:focus:border-blue-500 dark:focus:ring-blue-500',
'transition-colors duration-150',
].join(' ');
const FB_MUTED = [
'block w-full rounded-lg border border-gray-200 bg-gray-100 px-2.5 py-2 text-xs',
'text-gray-400 cursor-default select-none',
'dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500',
].join(' ');
// ─── component ───────────────────────────────────────────────────────────────
export default function TaskWorkHoursTableModal({
open,
task,
defaultLocation = '',
saving = false,
onClose,
onSave,
}) {
const [rows, setRows] = useState([]);
useEffect(() => {
if (!open) { setRows([]); return; }
const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : [];
setRows(
incoming.length > 0
? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) }))
: [{ ...EMPTY_ROW }, { ...EMPTY_ROW }, { ...EMPTY_ROW }],
);
}, [open, task?.id, task?.work_hours_table]);
if (!open || !task) return null;
// ── derived totals ─────────────────────────────────────────────────────
const totals = useMemo(() => {
const travel = rows.reduce((s, r) => s + parseNum(r.travel_hours), 0);
const work = rows.reduce((s, r) => s + parseNum(r.work_hours), 0);
const km = rows.reduce((s, r) => s + parseNum(r.vehicle_km), 0);
return {
travel: fmtH(travel),
work: fmtH(work),
km: Number.isInteger(km) ? String(km) : km.toFixed(1),
};
}, [rows]);
// ── row mutation ───────────────────────────────────────────────────────
const updateRowField = (index, field, value) => {
setRows((prev) => {
// Step 1: update the changed row with full mutual-exclusion + calc
let updated = prev.map((row, i) => {
if (i !== index) return row;
return computeRow({ ...row, [field]: value });
});
const changed = updated[index];
const gid = changed?._groupId;
const role = changed?._role;
if (!gid) return updated;
// Step 2: departure row changes → sync return row
if (role === 'departure') {
updated = updated.map((row, i) => {
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
let returnRow = { ...row };
// Mirror day/date always
if (field === 'day' || field === 'date') {
returnRow[field] = value;
}
// Mirror break_hours
if (field === 'break_hours') {
returnRow.break_hours = value;
}
// Mirror vehicle_km
if (field === 'vehicle_km') {
returnRow.vehicle_km = value;
}
// Mirror travel times + recalculate return travel_to from duration
if (field === 'travel_time_from' || field === 'travel_time_to') {
returnRow.travel_time_from = changed.travel_time_from;
// Recalculate return's travel_to: return departs when work ends (travel_time_from stays)
// and duration equals departure trip duration
const depDuration = calcHours(changed.travel_time_from, changed.travel_time_to);
if (depDuration !== null && returnRow.travel_time_from) {
const retFrom = parseTime(returnRow.travel_time_from);
if (retFrom !== null) returnRow.travel_time_to = formatTime(retFrom + depDuration);
}
}
// Mirror places (swapped): arrival of departure → departure of return
if (field === 'arrival_place') {
returnRow.departure_place = value;
}
if (field === 'departure_place') {
returnRow.arrival_place = value;
}
return computeRow(returnRow);
});
}
// Step 3: work row's work_time_to → fill return row's travel_time_from
// and recalculate return row's travel_time_to using departure duration
if (role === 'work' && field === 'work_time_to') {
const depRow = updated.find(r => r._groupId === gid && r._role === 'departure');
const depDuration = depRow ? calcHours(depRow.travel_time_from, depRow.travel_time_to) : null;
updated = updated.map((row, i) => {
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
const newFrom = value; // Put od = when work ends
const newTo = (depDuration !== null && parseTime(newFrom) !== null)
? formatTime(parseTime(newFrom) + depDuration)
: row.travel_time_to;
return computeRow({ ...row, travel_time_from: newFrom, travel_time_to: newTo });
});
}
return updated;
});
};
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
const addThreeRows = () => {
const today = new Date();
const dd = String(today.getDate()).padStart(2, '0');
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dateStr = `${dd}.${mm}.${today.getFullYear()}`;
const dayLabels = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB'];
const dayLabel = dayLabels[today.getDay()];
const gid = newGroupId();
const loc = defaultLocation || '';
setRows((prev) => [
...prev,
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' },
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' },
]);
};
const removeRow = (i) => setRows((prev) => prev.filter((_, idx) => idx !== i));
const handleSave = async () => {
await onSave?.({ rows: rows.map(stripInternal) });
};
// ── field rendering ────────────────────────────────────────────────────
const isDisabled = (row, f) => {
const mode = rowMode(row);
if (f.computed) return true;
if (f.travelOnly && mode === 'work') return true;
if (f.workOnly && mode === 'travel') return true;
return false;
};
const renderCell = (row, idx, f) => {
const disabled = isDisabled(row, f);
const cls = disabled ? FB_MUTED : FB_INPUT;
if (f.type === 'select') {
return (
<select
value={row[f.key] || ''}
onChange={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
disabled={disabled}
className={cls}
>
{DAY_OPTIONS.map((o) => <option key={o || '_'} value={o}>{o || '-'}</option>)}
</select>
);
}
return (
<input
type={f.type}
value={row[f.key] || ''}
onInput={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
readOnly={f.computed}
disabled={disabled}
className={cls}
placeholder={f.placeholder || ''}
step={f.step}
min={f.min}
/>
);
};
// ── render ─────────────────────────────────────────────────────────────
return (
<ModalShell
onClose={onClose}
overlayClassName="z-[70] overflow-y-auto p-4 pt-16"
contentClassName="flex min-h-full items-start justify-center"
panelClassName="w-full max-w-[96vw] rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
>
<div className="flex w-full flex-col">
{/* ── header ── */}
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
<div>
<h3 className="text-base font-semibold text-text-main">Tablica radnih sati</h3>
<p className="text-xs text-text-muted">{task.title || '-'}</p>
</div>
<button type="button" onClick={onClose} aria-label="Zatvori"
className="ml-4 rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"></button>
</div>
<div className="space-y-4 overflow-y-auto px-5 py-4">
{/* ── info banner ── */}
<div className="flex flex-wrap gap-x-4 gap-y-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
<span><span className="font-semibold">Put (oddo):</span> unesi Put od/do Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana.</span>
<span><span className="font-semibold">Rad (oddo):</span> unesi Vr. rada od/do Sati rada se računaju automatski; Km, Put, Mj. polaska/dolaska su blokirani.</span>
<span><span className="font-semibold">Pauza</span> umanjuje izračunate sate u oba slučaja.</span>
</div>
{/* ── toolbar ── */}
<div className="flex flex-wrap gap-2">
<button type="button" onClick={addRow}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
+ Dodaj redak
</button>
<button type="button" onClick={addThreeRows}
className="inline-flex items-center gap-1 rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50">
+ Dodaj standardna 3 retka (Put / Rad / Povratak)
</button>
</div>
{/* ── mobile cards (< xl) ── */}
<div className="space-y-3 xl:hidden">
{rows.length === 0 && (
<p className="text-sm text-gray-400 dark:text-gray-500">Nema unesenih redaka.</p>
)}
{rows.map((row, idx) => (
<div key={idx} className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-bold text-gray-500 dark:text-gray-400">#{idx + 1}</span>
<button type="button" onClick={() => removeRow(idx)}
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Obriši
</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{FIELD_CONFIGS.map((f) => (
<label key={f.key} className="flex flex-col gap-0.5">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">{f.label}</span>
{renderCell(row, idx, f)}
</label>
))}
</div>
</div>
))}
{rows.length > 0 && (
<div className="rounded-lg border border-gray-300 bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200">
UKUPNO
<span className="ml-2">Sati rada: <span className="text-green-700 dark:text-green-400">{totals.work} h</span></span>
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
<span>Sati puta: <span className="text-blue-700 dark:text-blue-400">{totals.travel} h</span></span>
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
<span>Km vozila: <span className="text-gray-800 dark:text-gray-100">{totals.km} km</span></span>
</div>
)}
</div>
{/* ── desktop table (≥ xl) ── */}
<div className="hidden xl:block">
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700"> <table className="min-w-[1580px] w-full bg-white text-xs dark:bg-gray-900">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-700 dark:bg-gray-800">
<th className="w-8 px-2 py-2.5 text-center text-gray-400 dark:text-gray-500">#</th>
{FIELD_CONFIGS.map((f) => (
<th key={f.key} className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap">
{f.label}
{f.computed && <span className="ml-1 text-[9px] font-normal text-indigo-400">(auto)</span>}
</th>
))}
<th className="px-2 py-2.5 text-gray-400 dark:text-gray-500">Akcija</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={FIELD_CONFIGS.length + 2}
className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
Nema unesenih redaka.
</td>
</tr>
)}
{rows.map((row, idx) => (
<tr key={idx}
className="border-t border-gray-100 odd:bg-white even:bg-gray-50/60 hover:bg-blue-50/30 dark:border-gray-700 dark:odd:bg-gray-900 dark:even:bg-gray-800/60 dark:hover:bg-blue-900/10">
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400 dark:text-gray-500">{idx + 1}</td>
{FIELD_CONFIGS.map((f) => (
<td key={f.key} className="px-1 py-1 align-middle">
{renderCell(row, idx, f)}
</td>
))}
<td className="px-1 py-1 align-middle">
<button type="button" onClick={() => removeRow(idx)}
className="whitespace-nowrap rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Obriši
</button>
</td>
</tr>
))}
{rows.length > 0 && (
/* cols: # day date work_from work_to travel_from travel_to break → 8 cols
then: work_hours travel_hours departure arrival vehicle_km action */
<tr className="border-t-2 border-gray-300 bg-gray-100 text-xs font-bold dark:border-gray-600 dark:bg-gray-800">
<td colSpan={8} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati rada:</span>{totals.work} h
</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati puta:</span>{totals.travel} h
</td>
<td colSpan={2} className="px-2 py-2" />
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Km:</span>{totals.km}
</td>
<td className="px-2 py-2" />
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* ── footer ── */}
<div className="flex items-center justify-end gap-3 border-t border-border-hairline px-5 py-3">
<button type="button" onClick={onClose} disabled={saving}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
Odustani
</button>
<button type="button" onClick={handleSave} disabled={saving}
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60 dark:bg-blue-700 dark:hover:bg-blue-600">
{saving ? 'Spremam…' : 'Spremi tablicu radnih sati'}
</button>
</div>
</div>
</ModalShell>
);
}

View File

@@ -7,11 +7,13 @@ import {
fetchWorkOrderById,
fetchWorkOrderInvoices,
fetchWorkOrderTaskServiceContext,
updateTaskWorkHoursTable,
} from '../../stores/fleetDashboardStore';
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatEntityCode } from '../../lib/displayIds';
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') {
@@ -53,6 +55,8 @@ export default function WorkOrderInvoicesPdfPage() {
const [taskContext, setTaskContext] = useState({ tasks: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingTask, setEditingTask] = useState(null);
const [savingWorkHours, setSavingWorkHours] = useState(false);
useEffect(() => {
hydrateAuthFromStorage();
@@ -149,6 +153,25 @@ export default function WorkOrderInvoicesPdfPage() {
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(mediaPaths);
const handleSaveWorkHoursTable = async (tableData) => {
if (!editingTask?.id) return;
setSavingWorkHours(true);
try {
const response = await updateTaskWorkHoursTable(editingTask.id, tableData);
setTaskContext((prev) => ({
...prev,
tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => (
String(task.id) === String(editingTask.id)
? { ...task, work_hours_table: response?.data ?? null }
: task
)),
}));
setEditingTask(null);
} finally {
setSavingWorkHours(false);
}
};
return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
@@ -196,6 +219,15 @@ export default function WorkOrderInvoicesPdfPage() {
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
</div>
<div className="mt-2">
<button
type="button"
onClick={() => setEditingTask(task)}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Uredi tablicu radnih sati
</button>
</div>
<div className="mt-3 space-y-3">
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
@@ -281,6 +313,14 @@ export default function WorkOrderInvoicesPdfPage() {
</div>
)}
</div>
<TaskWorkHoursTableModal
open={!!editingTask}
task={editingTask}
defaultLocation={workOrder?.location || ''}
saving={savingWorkHours}
onClose={() => setEditingTask(null)}
onSave={handleSaveWorkHoursTable}
/>
</section>
);
}

View File

@@ -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.');

View File

@@ -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()]);