Compare commits
2 Commits
e8272a00bb
...
9a7c7e3163
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7c7e3163 | ||
|
|
7da1c84139 |
@@ -210,6 +210,32 @@ def _docx_filename(work_order, doc_type):
|
|||||||
return f"{display_code}.work-order.docx"
|
return f"{display_code}.work-order.docx"
|
||||||
|
|
||||||
|
|
||||||
|
def _service_records_pdf_filename(work_order, task=None):
|
||||||
|
if not task:
|
||||||
|
return _pdf_filename(work_order, 'service_records')
|
||||||
|
display_code = _work_order_display_code(work_order)
|
||||||
|
return f"{display_code}.task-{task.pk}.work-order-service-records.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def _service_records_docx_filename(work_order, task=None):
|
||||||
|
if not task:
|
||||||
|
return _docx_filename(work_order, 'service_records')
|
||||||
|
display_code = _work_order_display_code(work_order)
|
||||||
|
return f"{display_code}.task-{task.pk}.work-order-service-records.docx"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_service_report_tasks(work_order, task_id):
|
||||||
|
queryset = _work_order_related_tasks_queryset(work_order)
|
||||||
|
if not task_id:
|
||||||
|
return list(queryset), None
|
||||||
|
selected_task = queryset.filter(pk=task_id).first()
|
||||||
|
if not selected_task:
|
||||||
|
raise DRFValidationError({
|
||||||
|
'task_id': 'Odabrani task nije povezan s ovim putnim nalogom.'
|
||||||
|
})
|
||||||
|
return [selected_task], selected_task
|
||||||
|
|
||||||
|
|
||||||
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
||||||
"""
|
"""
|
||||||
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
||||||
@@ -884,7 +910,7 @@ def _build_work_order_pdf(work_order):
|
|||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _build_work_order_service_records_pdf(work_order):
|
def _build_work_order_service_records_pdf(work_order, related_tasks=None):
|
||||||
vehicle = work_order.vehicle
|
vehicle = work_order.vehicle
|
||||||
client_name = getattr(vehicle.client, 'name', None) or '-'
|
client_name = getattr(vehicle.client, 'name', None) or '-'
|
||||||
manufacturer_str = str(vehicle.make or '-')
|
manufacturer_str = str(vehicle.make or '-')
|
||||||
@@ -900,17 +926,28 @@ def _build_work_order_service_records_pdf(work_order):
|
|||||||
FOOTER_H = 72
|
FOOTER_H = 72
|
||||||
MARGIN = 28
|
MARGIN = 28
|
||||||
|
|
||||||
|
if related_tasks is None:
|
||||||
|
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
||||||
|
else:
|
||||||
|
related_tasks = list(related_tasks)
|
||||||
|
# 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(
|
service_rows = list(
|
||||||
VehicleServiceRecord.objects.filter(
|
VehicleServiceRecord.objects.filter(
|
||||||
is_active=True,
|
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')
|
.select_related('performed_by', 'task')
|
||||||
.prefetch_related('photos')
|
.prefetch_related('photos')
|
||||||
.order_by('service_date', 'created_at')
|
.order_by('service_date', 'created_at')
|
||||||
)
|
)
|
||||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
# Per-task notes; fall back to work_order.notes
|
||||||
notes_text = str(work_order.notes or '').strip()
|
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()
|
buffer = BytesIO()
|
||||||
|
|
||||||
@@ -1804,7 +1841,7 @@ def _build_work_order_docx_bytes(work_order):
|
|||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _build_work_order_service_records_docx_bytes(work_order):
|
def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None):
|
||||||
from docx.shared import Cm
|
from docx.shared import Cm
|
||||||
|
|
||||||
doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME)
|
doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME)
|
||||||
@@ -1812,9 +1849,11 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
|||||||
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
|
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
|
||||||
servicer_name = _user_display_name(work_order.creator) or '-'
|
servicer_name = _user_display_name(work_order.creator) or '-'
|
||||||
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
|
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))
|
if related_tasks is None:
|
||||||
|
related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table'))
|
||||||
|
else:
|
||||||
|
related_tasks = list(related_tasks)
|
||||||
normalized_rows = []
|
normalized_rows = []
|
||||||
for task in related_tasks:
|
for task in related_tasks:
|
||||||
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
|
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
|
||||||
@@ -1847,10 +1886,13 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
|||||||
'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-',
|
'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(
|
service_rows = list(
|
||||||
VehicleServiceRecord.objects.filter(
|
VehicleServiceRecord.objects.filter(
|
||||||
is_active=True,
|
is_active=True,
|
||||||
vehicle_id=work_order.vehicle_id,
|
vehicle_id__in=task_vehicle_ids,
|
||||||
)
|
)
|
||||||
.select_related('performed_by', 'task')
|
.select_related('performed_by', 'task')
|
||||||
.prefetch_related('photos')
|
.prefetch_related('photos')
|
||||||
@@ -1861,6 +1903,14 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
|||||||
for task in related_tasks
|
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:
|
if len(doc.tables) >= 4:
|
||||||
info_table = doc.tables[0]
|
info_table = doc.tables[0]
|
||||||
_set_docx_cell_text(info_table, 1, 0, client_name)
|
_set_docx_cell_text(info_table, 1, 0, client_name)
|
||||||
@@ -1893,7 +1943,6 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
|||||||
target_row.cells[3].text = entry['changed_at']
|
target_row.cells[3].text = entry['changed_at']
|
||||||
|
|
||||||
details_table = doc.tables[3]
|
details_table = doc.tables[3]
|
||||||
issue_text = str(work_order.notes or '-').strip() or '-'
|
|
||||||
repair_lines = []
|
repair_lines = []
|
||||||
for task in related_tasks:
|
for task in related_tasks:
|
||||||
task_records = records_by_task.get(task.id, [])
|
task_records = records_by_task.get(task.id, [])
|
||||||
@@ -1904,7 +1953,7 @@ def _build_work_order_service_records_docx_bytes(work_order):
|
|||||||
description = str(record.description or '-').strip() or '-'
|
description = str(record.description or '-').strip() or '-'
|
||||||
repair_lines.append(f"- {description}")
|
repair_lines.append(f"- {description}")
|
||||||
repair_text = "\n".join(repair_lines) if repair_lines else '-'
|
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}")
|
_set_docx_cell_text(details_table, 1, 0, f"Popravak: {repair_text}")
|
||||||
else:
|
else:
|
||||||
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."})
|
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."})
|
||||||
@@ -2775,27 +2824,38 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
@action(detail=True, methods=['get'], url_path='service-records-pdf')
|
@action(detail=True, methods=['get'], url_path='service-records-pdf')
|
||||||
def service_records_pdf(self, request, pk=None):
|
def service_records_pdf(self, request, pk=None):
|
||||||
work_order = self.get_object()
|
work_order = self.get_object()
|
||||||
|
task_id = request.query_params.get('task_id')
|
||||||
|
related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id)
|
||||||
|
|
||||||
|
if not selected_task:
|
||||||
_cleanup_expired_generated_pdfs()
|
_cleanup_expired_generated_pdfs()
|
||||||
cached = _get_cached_pdf(work_order, 'service_records')
|
cached = _get_cached_pdf(work_order, 'service_records')
|
||||||
if cached:
|
if cached:
|
||||||
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records'))
|
return _cached_pdf_file_response(
|
||||||
|
cached,
|
||||||
|
default_filename=_service_records_pdf_filename(work_order),
|
||||||
|
)
|
||||||
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
|
||||||
pdf_bytes = _build_work_order_service_records_pdf(work_order)
|
|
||||||
|
pdf_bytes = _build_work_order_service_records_pdf(work_order, related_tasks=related_tasks)
|
||||||
|
|
||||||
|
if not selected_task:
|
||||||
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
|
||||||
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
||||||
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"'
|
response['Content-Disposition'] = f'attachment; filename="{_service_records_pdf_filename(work_order, selected_task)}"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='service-records-docx')
|
@action(detail=True, methods=['get'], url_path='service-records-docx')
|
||||||
def service_records_docx(self, request, pk=None):
|
def service_records_docx(self, request, pk=None):
|
||||||
work_order = self.get_object()
|
work_order = self.get_object()
|
||||||
docx_bytes = _build_work_order_service_records_docx_bytes(work_order)
|
task_id = request.query_params.get('task_id')
|
||||||
|
related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id)
|
||||||
|
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=related_tasks)
|
||||||
response = HttpResponse(
|
response = HttpResponse(
|
||||||
docx_bytes,
|
docx_bytes,
|
||||||
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
)
|
)
|
||||||
response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "service_records")}"'
|
response['Content-Disposition'] = f'attachment; filename="{_service_records_docx_filename(work_order, selected_task)}"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
|
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
|
||||||
@@ -2922,7 +2982,10 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
payload = []
|
payload = []
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
records_payload = []
|
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 = []
|
photos_payload = []
|
||||||
for photo in record.photos.filter(is_active=True).all():
|
for photo in record.photos.filter(is_active=True).all():
|
||||||
if not photo.image:
|
if not photo.image:
|
||||||
@@ -2950,6 +3013,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
'title': task.title,
|
'title': task.title,
|
||||||
'status': task.status,
|
'status': task.status,
|
||||||
'description': task.description,
|
'description': task.description,
|
||||||
|
'service_report_note': task.service_report_note or '',
|
||||||
'assigned_to_name': _user_display_name(task.assigned_to),
|
'assigned_to_name': _user_display_name(task.assigned_to),
|
||||||
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
|
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
|
||||||
'work_hours_table': getattr(getattr(task, 'work_hours_table', None), 'data', 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"),
|
verbose_name=_("Planirani datum izvršenja"),
|
||||||
help_text=_("Datum kada je planirano izvršenje radnog zadatka."),
|
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:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number',
|
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number',
|
||||||
'work_order', 'work_order_label',
|
'work_order', 'work_order_label',
|
||||||
'scheduled_date',
|
'scheduled_date',
|
||||||
|
'service_report_note',
|
||||||
'template_id', 'auto_close_work_order',
|
'template_id', 'auto_close_work_order',
|
||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
@@ -114,6 +115,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
'work_order': {'required': False, 'allow_null': True},
|
'work_order': {'required': False, 'allow_null': True},
|
||||||
'vehicle': {'required': False, 'allow_null': True},
|
'vehicle': {'required': False, 'allow_null': True},
|
||||||
'scheduled_date': {'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):
|
def validate_title(self, value):
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ const FB_MUTED = [
|
|||||||
export default function TaskWorkHoursTableModal({
|
export default function TaskWorkHoursTableModal({
|
||||||
open,
|
open,
|
||||||
task,
|
task,
|
||||||
|
workOrder = null,
|
||||||
defaultLocation = '',
|
defaultLocation = '',
|
||||||
saving = false,
|
saving = false,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -337,6 +338,39 @@ export default function TaskWorkHoursTableModal({
|
|||||||
|
|
||||||
<div className="space-y-4 overflow-y-auto px-5 py-4">
|
<div className="space-y-4 overflow-y-auto px-5 py-4">
|
||||||
|
|
||||||
|
{/* ── read-only work order travel data ── */}
|
||||||
|
{workOrder && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs dark:border-amber-800 dark:bg-amber-900/20">
|
||||||
|
<p className="mb-1.5 font-semibold text-amber-800 dark:text-amber-300">Podaci iz putnog naloga (samo za prikaz)</p>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-amber-900 dark:text-amber-200 sm:grid-cols-3">
|
||||||
|
{workOrder.origin_location && (
|
||||||
|
<div><span className="font-medium">Ishodište:</span> {workOrder.origin_location}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.location && (
|
||||||
|
<div><span className="font-medium">Odredište:</span> {workOrder.location}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.travel_start_at && (
|
||||||
|
<div><span className="font-medium">Polazak:</span> {new Date(workOrder.travel_start_at).toLocaleString('hr-HR', { dateStyle: 'short', timeStyle: 'short' })}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.travel_end_at && (
|
||||||
|
<div><span className="font-medium">Povratak:</span> {new Date(workOrder.travel_end_at).toLocaleString('hr-HR', { dateStyle: 'short', timeStyle: 'short' })}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.start_mileage != null && (
|
||||||
|
<div><span className="font-medium">KM početak:</span> {workOrder.start_mileage}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.end_mileage != null && (
|
||||||
|
<div><span className="font-medium">KM kraj:</span> {workOrder.end_mileage}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.distance != null && (
|
||||||
|
<div><span className="font-medium">Prijeđeno km:</span> {workOrder.distance}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.servicer_vehicle_registration && (
|
||||||
|
<div><span className="font-medium">Vozilo servisera:</span> {workOrder.servicer_vehicle_registration} {workOrder.servicer_vehicle_make_model || ''}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── info banner ── */}
|
{/* ── 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">
|
<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">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>
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ import {
|
|||||||
fetchWorkOrderInvoices,
|
fetchWorkOrderInvoices,
|
||||||
fetchWorkOrderTaskServiceContext,
|
fetchWorkOrderTaskServiceContext,
|
||||||
updateTaskWorkHoursTable,
|
updateTaskWorkHoursTable,
|
||||||
updateWorkOrder,
|
updateTaskServiceReportNote,
|
||||||
updateWorkOrderAdditionalCostsTable,
|
updateWorkOrderAdditionalCostsTable,
|
||||||
} from '../../stores/fleetDashboardStore';
|
} from '../../stores/fleetDashboardStore';
|
||||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { showToast } from '../../stores/toastStore';
|
|
||||||
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||||
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||||
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
||||||
@@ -155,9 +154,9 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
|
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
|
||||||
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
|
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
|
||||||
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
|
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
|
||||||
const [editingServiceReportNote, setEditingServiceReportNote] = useState(false);
|
const [editingTaskNote, setEditingTaskNote] = useState(null);
|
||||||
const [savingServiceReportNote, setSavingServiceReportNote] = useState(false);
|
const [savingTaskNote, setSavingTaskNote] = useState(false);
|
||||||
const [serviceReportNoteError, setServiceReportNoteError] = useState('');
|
const [taskNoteError, setTaskNoteError] = useState('');
|
||||||
const [invoiceDetailModal, setInvoiceDetailModal] = useState(null);
|
const [invoiceDetailModal, setInvoiceDetailModal] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,6 +276,29 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveTaskServiceReportNote = async (note) => {
|
||||||
|
if (!editingTaskNote?.id) return;
|
||||||
|
setSavingTaskNote(true);
|
||||||
|
setTaskNoteError('');
|
||||||
|
try {
|
||||||
|
const updated = await updateTaskServiceReportNote(editingTaskNote.id, note);
|
||||||
|
setTaskContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => (
|
||||||
|
String(task.id) === String(editingTaskNote.id)
|
||||||
|
? { ...task, service_report_note: updated?.service_report_note ?? note }
|
||||||
|
: task
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
setEditingTaskNote(null);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.message || 'Spremanje napomene nije uspjelo.';
|
||||||
|
setTaskNoteError(message);
|
||||||
|
} finally {
|
||||||
|
setSavingTaskNote(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveAdditionalCostsTable = async (tableData) => {
|
const handleSaveAdditionalCostsTable = async (tableData) => {
|
||||||
if (!workOrderId) return;
|
if (!workOrderId) return;
|
||||||
setSavingAdditionalCosts(true);
|
setSavingAdditionalCosts(true);
|
||||||
@@ -289,24 +311,6 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveServiceReportNote = async (notes) => {
|
|
||||||
if (!workOrderId) return;
|
|
||||||
setSavingServiceReportNote(true);
|
|
||||||
setServiceReportNoteError('');
|
|
||||||
try {
|
|
||||||
const updated = await updateWorkOrder(workOrderId, { notes: String(notes || '') });
|
|
||||||
setWorkOrder(updated);
|
|
||||||
setEditingServiceReportNote(false);
|
|
||||||
showToast('Napomena za izvještaj servisera je spremljena.', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
const message = err?.message || 'Spremanje napomene nije uspjelo.';
|
|
||||||
setServiceReportNoteError(message);
|
|
||||||
showToast(message, 'error');
|
|
||||||
} finally {
|
|
||||||
setSavingServiceReportNote(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
<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">
|
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||||
@@ -340,33 +344,13 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
|
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const firstTask = Array.isArray(taskContext.tasks) ? taskContext.tasks[0] : null;
|
|
||||||
if (!firstTask) return;
|
|
||||||
setEditingTask(firstTask);
|
|
||||||
}}
|
|
||||||
disabled={!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0}
|
|
||||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
|
||||||
>
|
|
||||||
Uredi tablicu radnih sati
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingServiceReportNote(true)}
|
|
||||||
disabled={!workOrderId}
|
|
||||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
|
||||||
>
|
|
||||||
Uredi tekst napomene
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
||||||
disabled={!workOrderId}
|
disabled={!workOrderId}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
Preuzmi PDF servisnih zapisa
|
Preuzmi PDF svih servisnih zapisa
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -374,7 +358,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
disabled={!workOrderId}
|
disabled={!workOrderId}
|
||||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
>
|
>
|
||||||
Preuzmi DOCX servisnih zapisa
|
Preuzmi DOCX svih servisnih zapisa
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,13 +370,52 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{taskContext.tasks.map((task) => (
|
{taskContext.tasks.map((task) => (
|
||||||
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||||
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="space-y-1 text-sm text-text-main">
|
<div className="space-y-1 text-sm text-text-main">
|
||||||
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
|
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
|
||||||
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
|
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
|
||||||
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
|
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
|
||||||
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
|
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
|
||||||
|
{task.service_report_note && (
|
||||||
|
<div className="mt-1 rounded bg-canvas-deep px-2 py-1 text-xs text-text-muted">
|
||||||
|
<span className="font-semibold">Napomena:</span> {task.service_report_note}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-3">
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingTask(task)}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
Uredi tablicu radnih sati
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setTaskNoteError(''); setEditingTaskNote(task); }}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
Uredi tekst napomene
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId, task.id)}
|
||||||
|
disabled={!workOrderId || !task.id}
|
||||||
|
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
PDF za ovaj task
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderServiceRecordsDocx(workOrderId, task.id)}
|
||||||
|
disabled={!workOrderId || !task.id}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
DOCX za ovaj task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
|
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
|
||||||
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
|
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -517,6 +540,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
<TaskWorkHoursTableModal
|
<TaskWorkHoursTableModal
|
||||||
open={!!editingTask}
|
open={!!editingTask}
|
||||||
task={editingTask}
|
task={editingTask}
|
||||||
|
workOrder={workOrder}
|
||||||
defaultLocation={workOrder?.location || ''}
|
defaultLocation={workOrder?.location || ''}
|
||||||
saving={savingWorkHours}
|
saving={savingWorkHours}
|
||||||
onClose={() => setEditingTask(null)}
|
onClose={() => setEditingTask(null)}
|
||||||
@@ -531,15 +555,15 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
onSave={handleSaveAdditionalCostsTable}
|
onSave={handleSaveAdditionalCostsTable}
|
||||||
/>
|
/>
|
||||||
<WorkOrderServiceNotesModal
|
<WorkOrderServiceNotesModal
|
||||||
open={editingServiceReportNote}
|
open={!!editingTaskNote}
|
||||||
workOrder={workOrder}
|
task={editingTaskNote}
|
||||||
saving={savingServiceReportNote}
|
saving={savingTaskNote}
|
||||||
error={serviceReportNoteError}
|
error={taskNoteError}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditingServiceReportNote(false);
|
setEditingTaskNote(null);
|
||||||
setServiceReportNoteError('');
|
setTaskNoteError('');
|
||||||
}}
|
}}
|
||||||
onSave={handleSaveServiceReportNote}
|
onSave={handleSaveTaskServiceReportNote}
|
||||||
/>
|
/>
|
||||||
{invoiceDetailModal && (
|
{invoiceDetailModal && (
|
||||||
<InvoiceDetailModal
|
<InvoiceDetailModal
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ModalShell from '../ui/ModalShell';
|
|||||||
|
|
||||||
export default function WorkOrderServiceNotesModal({
|
export default function WorkOrderServiceNotesModal({
|
||||||
open,
|
open,
|
||||||
workOrder,
|
task,
|
||||||
saving = false,
|
saving = false,
|
||||||
error = '',
|
error = '',
|
||||||
onClose,
|
onClose,
|
||||||
@@ -16,8 +16,8 @@ export default function WorkOrderServiceNotesModal({
|
|||||||
setNotes('');
|
setNotes('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setNotes(String(workOrder?.notes || ''));
|
setNotes(String(task?.service_report_note || ''));
|
||||||
}, [open, workOrder?.id, workOrder?.notes]);
|
}, [open, task?.id, task?.service_report_note]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -37,7 +37,9 @@ export default function WorkOrderServiceNotesModal({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-text-main">UREDI TEKST NAPOMENE</h3>
|
<h3 className="text-base font-semibold text-text-main">UREDI TEKST NAPOMENE</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
Tekst se prikazuje u PDF-u "Izvještaj servisera" na prvoj stranici.
|
{task?.title
|
||||||
|
? `Zadatak: ${task.title}`
|
||||||
|
: 'Tekst se prikazuje u PDF-u "Izvještaj servisera".'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -56,11 +58,11 @@ export default function WorkOrderServiceNotesModal({
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<label htmlFor="work-order-notes" className="text-sm font-medium text-text-main">
|
<label htmlFor="task-service-report-note" className="text-sm font-medium text-text-main">
|
||||||
Napomena
|
Napomena
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="work-order-notes"
|
id="task-service-report-note"
|
||||||
value={notes}
|
value={notes}
|
||||||
onInput={(event) => setNotes(event.currentTarget.value)}
|
onInput={(event) => setNotes(event.currentTarget.value)}
|
||||||
rows={8}
|
rows={8}
|
||||||
|
|||||||
@@ -771,6 +771,17 @@ export async function updateTaskWorkHoursTable(taskId, data) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTaskServiceReportNote(taskId, note) {
|
||||||
|
if (!taskId) {
|
||||||
|
throw new Error('Task ID je obavezan.');
|
||||||
|
}
|
||||||
|
const payload = await api.patch(`tasks/tasks/${encodeURIComponent(taskId)}/`, {
|
||||||
|
service_report_note: String(note ?? ''),
|
||||||
|
});
|
||||||
|
showToast('Napomena servisnog izvještaja je uspješno spremljena.', 'success');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
|
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
@@ -854,10 +865,25 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
|
if (taskId) {
|
||||||
|
try {
|
||||||
|
const blob = await api.get(
|
||||||
|
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`,
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
);
|
||||||
|
saveBlobToFile(blob, `${workOrderId}.task-${taskId}.work-order-service-records.pdf`);
|
||||||
|
showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success');
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message || 'Preuzimanje PDF-a servisnih zapisa za task nije uspjelo.';
|
||||||
|
showToast(msg, 'error');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
|
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
|
||||||
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
|
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
|
||||||
if (payload?.status === 'ready' && payload?.download_url) {
|
if (payload?.status === 'ready' && payload?.download_url) {
|
||||||
@@ -886,14 +912,23 @@ export async function downloadWorkOrderDocx(workOrderId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
|
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
|
const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : '';
|
||||||
try {
|
try {
|
||||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
|
const blob = await api.get(
|
||||||
|
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/${query}`,
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
);
|
||||||
|
if (taskId) {
|
||||||
|
saveBlobToFile(blob, `${workOrderId}.task-${taskId}.work-order-service-records.docx`);
|
||||||
|
showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success');
|
||||||
|
} else {
|
||||||
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
||||||
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
|
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
|
||||||
showToast(msg, 'error');
|
showToast(msg, 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user