interaktivna Tablica radnih sati, db
This commit is contained in:
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
476
frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
Normal file
476
frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
Normal 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 (od–do):</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 (od–do):</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
Reference in New Issue
Block a user