feat: per-task work hours table, notes, and read-only work order data in PDF page
Backend: - Added service_report_note TextField to Task model (migration 0012) - Exposed service_report_note in TaskSerializer - task_service_context: fixed service record filter from work_order.vehicle_id -> task.vehicle_id (bug: cross-crane work orders returned wrong records for non-primary crane) - task_service_context: include service_report_note in each task payload - _build_work_order_service_records_docx_bytes: use per-task service_report_note (fallback: work_order.notes); fix service records query to use task vehicle ids for cross-crane support - PDF generator: same per-task notes and cross-crane vehicle fix Frontend: - WorkOrderInvoicesPdfPage: removed global 'Uredi tablicu radnih sati' / 'Uredi tekst napomene' buttons that always opened tasks[0]; replaced with per-task buttons inside each task article - WorkOrderInvoicesPdfPage: separate editingTaskNote state / handler that calls updateTaskServiceReportNote (PATCH task.service_report_note) - WorkOrderServiceNotesModal: now receives task prop instead of workOrder; reads/writes task.service_report_note; shows task title in header - TaskWorkHoursTableModal: added workOrder prop; renders read-only amber info block with travel dates, mileage, and servicer vehicle data - fleetDashboardStore: added updateTaskServiceReportNote() function - WorkOrderInvoicesPdfPage: show saved service_report_note inline on each task card for quick reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -900,17 +900,25 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
FOOTER_H = 72
|
||||
MARGIN = 28
|
||||
|
||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
||||
# Collect service records for all task vehicles (supports cross-crane work orders)
|
||||
task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id})
|
||||
service_rows = list(
|
||||
VehicleServiceRecord.objects.filter(
|
||||
is_active=True,
|
||||
vehicle_id=work_order.vehicle_id,
|
||||
vehicle_id__in=task_vehicle_ids if task_vehicle_ids else [work_order.vehicle_id],
|
||||
)
|
||||
.select_related('performed_by', 'task')
|
||||
.prefetch_related('photos')
|
||||
.order_by('service_date', 'created_at')
|
||||
)
|
||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
||||
notes_text = str(work_order.notes or '').strip()
|
||||
# Per-task notes; fall back to work_order.notes
|
||||
task_note_parts = [
|
||||
str(task.service_report_note or '').strip()
|
||||
for task in related_tasks
|
||||
if str(task.service_report_note or '').strip()
|
||||
]
|
||||
notes_text = "\n".join(task_note_parts) if task_note_parts else str(work_order.notes or '').strip()
|
||||
|
||||
buffer = BytesIO()
|
||||
|
||||
@@ -1812,9 +1820,8 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
||||
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
|
||||
servicer_name = _user_display_name(work_order.creator) or '-'
|
||||
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
|
||||
notes_text = str(work_order.notes or '').strip() or '-'
|
||||
|
||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
||||
related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table'))
|
||||
normalized_rows = []
|
||||
for task in related_tasks:
|
||||
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
|
||||
@@ -1847,10 +1854,13 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
||||
'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-',
|
||||
})
|
||||
|
||||
# Collect service records per task, filtering by each task's own vehicle
|
||||
# (supports cross-crane work orders where each task may have a different crane).
|
||||
task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id})
|
||||
service_rows = list(
|
||||
VehicleServiceRecord.objects.filter(
|
||||
is_active=True,
|
||||
vehicle_id=work_order.vehicle_id,
|
||||
vehicle_id__in=task_vehicle_ids,
|
||||
)
|
||||
.select_related('performed_by', 'task')
|
||||
.prefetch_related('photos')
|
||||
@@ -1861,6 +1871,14 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
||||
for task in related_tasks
|
||||
}
|
||||
|
||||
# Build notes: use per-task service_report_note; fall back to work_order.notes
|
||||
task_note_parts = [
|
||||
str(task.service_report_note or '').strip()
|
||||
for task in related_tasks
|
||||
if str(task.service_report_note or '').strip()
|
||||
]
|
||||
notes_text = "\n".join(task_note_parts) if task_note_parts else (str(work_order.notes or '').strip() or '-')
|
||||
|
||||
if len(doc.tables) >= 4:
|
||||
info_table = doc.tables[0]
|
||||
_set_docx_cell_text(info_table, 1, 0, client_name)
|
||||
@@ -1893,7 +1911,6 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
||||
target_row.cells[3].text = entry['changed_at']
|
||||
|
||||
details_table = doc.tables[3]
|
||||
issue_text = str(work_order.notes or '-').strip() or '-'
|
||||
repair_lines = []
|
||||
for task in related_tasks:
|
||||
task_records = records_by_task.get(task.id, [])
|
||||
@@ -1904,7 +1921,7 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
||||
description = str(record.description or '-').strip() or '-'
|
||||
repair_lines.append(f"- {description}")
|
||||
repair_text = "\n".join(repair_lines) if repair_lines else '-'
|
||||
_set_docx_cell_text(details_table, 0, 0, f"Kvar: {issue_text}")
|
||||
_set_docx_cell_text(details_table, 0, 0, f"Kvar: {notes_text}")
|
||||
_set_docx_cell_text(details_table, 1, 0, f"Popravak: {repair_text}")
|
||||
else:
|
||||
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."})
|
||||
@@ -2922,7 +2939,10 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
payload = []
|
||||
for task in tasks:
|
||||
records_payload = []
|
||||
for record in task.service_records.filter(is_active=True, vehicle_id=work_order.vehicle_id).all():
|
||||
# Filter records by the task's own vehicle (not work_order.vehicle) to support
|
||||
# cross-crane work orders where each task may belong to a different crane.
|
||||
task_vehicle_id = task.vehicle_id
|
||||
for record in task.service_records.filter(is_active=True, vehicle_id=task_vehicle_id).all():
|
||||
photos_payload = []
|
||||
for photo in record.photos.filter(is_active=True).all():
|
||||
if not photo.image:
|
||||
@@ -2950,6 +2970,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
'title': task.title,
|
||||
'status': task.status,
|
||||
'description': task.description,
|
||||
'service_report_note': task.service_report_note or '',
|
||||
'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),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task_management', '0011_taskcalendar'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='service_report_note',
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='Napomena izvještaja servisera',
|
||||
help_text='Tekst napomene za PDF izvještaj servisera za ovaj zadatak.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -54,6 +54,12 @@ class Task(BaseModel):
|
||||
verbose_name=_("Planirani datum izvršenja"),
|
||||
help_text=_("Datum kada je planirano izvršenje radnog zadatka."),
|
||||
)
|
||||
service_report_note = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name=_("Napomena izvještaja servisera"),
|
||||
help_text=_("Tekst napomene za PDF izvještaj servisera za ovaj zadatak."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
@@ -103,6 +103,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number',
|
||||
'work_order', 'work_order_label',
|
||||
'scheduled_date',
|
||||
'service_report_note',
|
||||
'template_id', 'auto_close_work_order',
|
||||
'created_at',
|
||||
]
|
||||
@@ -114,6 +115,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
'work_order': {'required': False, 'allow_null': True},
|
||||
'vehicle': {'required': False, 'allow_null': True},
|
||||
'scheduled_date': {'required': False, 'allow_null': True},
|
||||
'service_report_note': {'required': False, 'allow_blank': True},
|
||||
}
|
||||
|
||||
def validate_title(self, value):
|
||||
|
||||
Reference in New Issue
Block a user