2 Commits

Author SHA1 Message Date
mariomitte
ae6ae08fb1 feat: dodaj DOCX akcije i napomene na racunima
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- dodaje DOCX preuzimanje za putni nalog, servisne zapise i racune na /putni-nalozi/racuni\n- uvodi modal za uredjivanje teksta napomene izvjestaja servisera\n- poboljsava update work order toka i upsert lokalnog stanja\n- ukljucuje TaskCalendar migraciju, dev watch stabilizaciju i deploy upute

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 13:34:53 +02:00
mariomitte
1055820741 fix: uskladi servisni DOCX template i deduplikaciju dijelova
- koristi novi Servisni N-R template za service-records DOCX\n- premjesta tablicu radnih sati odmah ispod naslova na prvoj stranici\n- deduplicira ponovljene unose koristenih dijelova u tablici artikala\n- uklanja fallback neautorizirani media URL za zasticene fleet resurse

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 13:27:13 +02:00
12 changed files with 741 additions and 7 deletions

View File

@@ -1 +1,23 @@
Frontend + Backend + Database + Deployment Frontend + Backend + Database + Deployment
### Deploy na serveru
Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promjena iz repozitorija
```bash
cd /opt/erp/app
git fetch origin
git reset --hard origin/main
# rebuild backend + worker + beat + frontend
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
# digni servise s novim image-ima
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --force-recreate backend worker beat frontend
# OBAVEZNO migracije (scheduled_date)
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend python manage.py migrate
# provjera
curl -i https://api-004.captain.mitteworkspace.cloud/api/health/
```

View File

@@ -0,0 +1,10 @@
CONTACT ID,EMAIL,sib_contact_owner,LASTNAME,FIRSTNAME,_DETECTED_LANGUAGE,DOUBLE_OPT-IN,OPT_IN,CONTACT_TIMEZONE,EXT_ID,LINKEDIN,JOB_TITLE,WHATSAPP,LANDLINE_NUMBER,SMS,ADDED_TIME,MODIFIED_TIME
1,mtkalac.mt@gmail.com,,TKALAC,MARIO,,,,,,,,,,385955002483,20-07-2026,20-07-2026
2,domagoj@knezljubo.hr,,Knez,Domagoj,Croatian,No,NO,(GMT+02:00) Zagreb,domagojknezljubo,,direktor,385913228904,,385913228904,20-07-2026,20-07-2026
3,ljubo@knezljubo.hr,,Ljubo,Knez,Croatian,No,NO,(GMT+02:00) Zagreb,ljuboknezljubo,,direktor,385913228902,,385913228902,20-07-2026,20-07-2026
4,mario.tkalac@knezljubo.hr,,Tkalac,Mario,Croatian,No,NO,(GMT+02:00) Zagreb,mariotkalacknezljubo,,serviser,385916303466,,385916303466,20-07-2026,20-07-2026
5,fran@knezljubo.hr,,Mati<EFBFBD>,Fran,Croatian,No,NO,(GMT+02:00) Zagreb,franmaticknezljubo,,prodaja,385916004943,,385916004943,20-07-2026,20-07-2026
6,iva.markic@knezljubo.hr,,Marki<EFBFBD>,Iva,Croatian,No,NO,(GMT+02:00) Zagreb,ivamarkicknezljubo,,knjigovo<EFBFBD>a,385919423525,,385919423525,20-07-2026,20-07-2026
7,ramo@knezljubo.hr,,Husanovi<EFBFBD>,Ramo,Croatian,No,NO,(GMT+02:00) Zagreb,ramohusanovicknezljubo,,serviser,385913228910,,385913228910,20-07-2026,20-07-2026
8,office@knezljubo.hr,,Cepi<EFBFBD>,Dario,Croatian,No,NO,(GMT+02:00) Zagreb,dariocepicknezljubo,logistika,385994067776,,385994067776,20-07-2026,20-07-2026
9,josip@knezljubo.hr,,Pintari<EFBFBD>,Josip,Croatian,No,NO,(GMT+02:00) Zagreb,josippintaricknezljubo,serviser,385913228906,,385913228906,20-07-2026,20-07-2026
1 CONTACT ID,EMAIL,sib_contact_owner,LASTNAME,FIRSTNAME,_DETECTED_LANGUAGE,DOUBLE_OPT-IN,OPT_IN,CONTACT_TIMEZONE,EXT_ID,LINKEDIN,JOB_TITLE,WHATSAPP,LANDLINE_NUMBER,SMS,ADDED_TIME,MODIFIED_TIME
2 1,mtkalac.mt@gmail.com,,TKALAC,MARIO,,,,,,,,,,385955002483,20-07-2026,20-07-2026
3 2,domagoj@knezljubo.hr,,Knez,Domagoj,Croatian,No,NO,(GMT+02:00) Zagreb,domagojknezljubo,,direktor,385913228904,,385913228904,20-07-2026,20-07-2026
4 3,ljubo@knezljubo.hr,,Ljubo,Knez,Croatian,No,NO,(GMT+02:00) Zagreb,ljuboknezljubo,,direktor,385913228902,,385913228902,20-07-2026,20-07-2026
5 4,mario.tkalac@knezljubo.hr,,Tkalac,Mario,Croatian,No,NO,(GMT+02:00) Zagreb,mariotkalacknezljubo,,serviser,385916303466,,385916303466,20-07-2026,20-07-2026
6 5,fran@knezljubo.hr,,Matiæ,Fran,Croatian,No,NO,(GMT+02:00) Zagreb,franmaticknezljubo,,prodaja,385916004943,,385916004943,20-07-2026,20-07-2026
7 6,iva.markic@knezljubo.hr,,Markiæ,Iva,Croatian,No,NO,(GMT+02:00) Zagreb,ivamarkicknezljubo,,knjigovoða,385919423525,,385919423525,20-07-2026,20-07-2026
8 7,ramo@knezljubo.hr,,Husanoviæ,Ramo,Croatian,No,NO,(GMT+02:00) Zagreb,ramohusanovicknezljubo,,serviser,385913228910,,385913228910,20-07-2026,20-07-2026
9 8,office@knezljubo.hr,,Cepiæ,Dario,Croatian,No,NO,(GMT+02:00) Zagreb,dariocepicknezljubo,logistika,385994067776,,385994067776,20-07-2026,20-07-2026
10 9,josip@knezljubo.hr,,Pintariæ,Josip,Croatian,No,NO,(GMT+02:00) Zagreb,josippintaricknezljubo,serviser,385913228906,,385913228906,20-07-2026,20-07-2026

Binary file not shown.

View File

@@ -6,6 +6,7 @@ import base64
import csv import csv
import mimetypes import mimetypes
import threading import threading
import re
from collections import OrderedDict from collections import OrderedDict
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@@ -83,6 +84,8 @@ from .tasks import (
register_unicode_fonts() register_unicode_fonts()
User = get_user_model() User = get_user_model()
DOCX_TEMPLATE_DIR = Path(__file__).resolve().parent / 'docx_templates'
SERVICE_REPORT_DOCX_TEMPLATE_NAME = 'Servisni N-R.docx'
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
@@ -196,6 +199,15 @@ def _pdf_filename(work_order, pdf_type):
return f"{display_code}.work-order.pdf" return f"{display_code}.work-order.pdf"
def _docx_filename(work_order, doc_type):
display_code = _work_order_display_code(work_order)
if doc_type == 'invoices':
return f"{display_code}.work-order-invoices.docx"
if doc_type == 'service_records':
return f"{display_code}.work-order-service-records.docx"
return f"{display_code}.work-order.docx"
def _get_cached_pdf(work_order, pdf_type): def _get_cached_pdf(work_order, pdf_type):
now = timezone.now() now = timezone.now()
expected_filename = _pdf_filename(work_order, pdf_type) expected_filename = _pdf_filename(work_order, pdf_type)
@@ -1532,6 +1544,405 @@ def _build_work_order_invoices_pdf_bytes(work_order):
raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."}) raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."})
def _create_docx_document(template_name=None):
try:
from docx import Document
except ModuleNotFoundError:
raise DRFValidationError({"detail": "DOCX generiranje nije dostupno: nedostaje python-docx paket."})
if not template_name:
return Document()
template_path = DOCX_TEMPLATE_DIR / template_name
if not template_path.exists():
raise DRFValidationError({"detail": f"DOCX template nije pronađen: {template_name}."})
return Document(str(template_path))
def _set_docx_cell_text(table, row_index, col_index, value):
row_count = len(getattr(table, 'rows', []))
if row_count <= row_index:
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."})
col_count = len(getattr(table.rows[row_index], 'cells', []))
if col_count <= col_index:
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."})
table.rows[row_index].cells[col_index].text = str(value or '-')
def _docx_add_heading(document, text, level=1):
style_name = f'Heading {int(level)}'
try:
document.styles[style_name]
return document.add_paragraph(str(text or ''), style=style_name)
except KeyError:
paragraph = document.add_paragraph()
run = paragraph.add_run(str(text or ''))
run.bold = True
return paragraph
def _normalize_whitespace(value):
return " ".join(str(value or '').split()).strip()
def _docx_remove_rows_after(table, keep_rows=1):
rows = list(getattr(table, 'rows', []))
for row in rows[keep_rows:]:
table._tbl.remove(row._tr)
def _docx_move_table_after_paragraph_text(document, table, marker_text):
if not marker_text:
return False
marker_norm = _normalize_whitespace(marker_text).lower()
if not marker_norm:
return False
body = document._body._element
children = list(body)
table_element = table._tbl
paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'
text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'
for index, element in enumerate(children):
if element.tag != paragraph_namespace:
continue
paragraph_text = ''.join(node.text or '' for node in element.iter(text_namespace))
if marker_norm not in _normalize_whitespace(paragraph_text).lower():
continue
if table_element in children:
body.remove(table_element)
children = list(body)
index = children.index(element)
body.insert(index + 1, table_element)
return True
return False
def _extract_unique_parts_entries(records):
unique = OrderedDict()
for record in records:
raw_parts = _normalize_whitespace(getattr(record, 'parts', None))
if not raw_parts or raw_parts == '-':
continue
key = raw_parts.lower()
if key in unique:
continue
serial = '-'
description = raw_parts
match = re.match(r'^\[([^\]]+)\]\s*(.*)$', raw_parts)
if match:
serial = _normalize_whitespace(match.group(1)) or '-'
description = _normalize_whitespace(match.group(2)) or '-'
unique[key] = {
'serial': serial,
'description': description,
'note': '-',
'changed_at': record.service_date.strftime('%d.%m.%Y') if record.service_date else '-',
}
return list(unique.values())
def _docx_cleanup_service_report_template(document):
body = document._body._element
children = list(body)
paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'
text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'
seen_hours_heading = False
seen_note = False
remove_tail = False
for element in children:
tag = element.tag
if tag == paragraph_namespace:
text = ''.join(node.text or '' for node in element.iter(text_namespace))
normalized = _normalize_whitespace(text)
normalized_lower = normalized.lower()
if 'servisni zapisi' in normalized_lower:
remove_tail = True
if remove_tail:
body.remove(element)
continue
if normalized_lower == 'tablica radnih sati':
if seen_hours_heading:
body.remove(element)
continue
seen_hours_heading = True
if normalized_lower.startswith('napomene:'):
if seen_note:
body.remove(element)
continue
seen_note = True
elif remove_tail and not tag.endswith('sectPr'):
body.remove(element)
def _build_work_order_docx_bytes(work_order):
vehicle = work_order.vehicle
creator = work_order.creator
doc = _create_docx_document()
_docx_add_heading(doc, f'Putni nalog {_work_order_display_code(work_order)}', level=1)
rows = [
('Datum naloga', str(work_order.date or '-')),
('Serviser', _user_display_name(creator) or getattr(creator, 'email', '-') or '-'),
('Klijent', getattr(getattr(vehicle, 'client', None), 'name', None) or '-'),
('Dizalica', " ".join(part for part in [vehicle.registration_number, vehicle.make, vehicle.model] if part) or '-'),
('Lokacija', work_order.location or '-'),
('Ishodište', work_order.origin_location or '-'),
('Svrha', work_order.purpose or '-'),
('Status', work_order.status or '-'),
('Napomene', work_order.notes or '-'),
]
table = doc.add_table(rows=1, cols=2)
table.rows[0].cells[0].text = 'Polje'
table.rows[0].cells[1].text = 'Vrijednost'
for label, value in rows:
row_cells = table.add_row().cells
row_cells[0].text = str(label)
row_cells[1].text = str(value)
additional_costs_table = getattr(work_order, 'additional_costs_table', None)
if additional_costs_table and isinstance(additional_costs_table.data, dict):
additional_rows = additional_costs_table.data.get('rows', [])
if isinstance(additional_rows, list) and additional_rows:
doc.add_paragraph()
_docx_add_heading(doc, 'Dodatni troškovi', level=2)
costs_table = doc.add_table(rows=1, cols=4)
costs_table.rows[0].cells[0].text = 'Naziv'
costs_table.rows[0].cells[1].text = 'Broj računa'
costs_table.rows[0].cells[2].text = 'Ukupan iznos'
costs_table.rows[0].cells[3].text = 'Prilog'
for entry in additional_rows:
if not isinstance(entry, dict):
continue
row_cells = costs_table.add_row().cells
row_cells[0].text = str(entry.get('naziv') or '-')
row_cells[1].text = str(entry.get('broj_racuna') or '-')
row_cells[2].text = str(entry.get('ukupan_iznos') or '-')
row_cells[3].text = str(entry.get('prilog') or '-')
buffer = BytesIO()
doc.save(buffer)
return buffer.getvalue()
def _build_work_order_service_records_docx_bytes(work_order):
doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME)
vehicle = work_order.vehicle
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))
normalized_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 row in rows:
if not isinstance(row, dict):
continue
normalized_rows.append({
'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 '-',
})
service_rows = list(
VehicleServiceRecord.objects.filter(
is_active=True,
vehicle_id=work_order.vehicle_id,
)
.select_related('performed_by', 'task')
.order_by('service_date', 'created_at')
)
records_by_task = {
task.id: [row for row in service_rows if getattr(row, 'task_id', None) == task.id]
for task in related_tasks
}
if len(doc.tables) >= 4:
info_table = doc.tables[0]
_set_docx_cell_text(info_table, 1, 0, client_name)
_set_docx_cell_text(info_table, 1, 1, work_order.location or '-')
_set_docx_cell_text(info_table, 3, 0, servicer_name)
if len(info_table.rows[3].cells) > 1:
_set_docx_cell_text(info_table, 3, 1, '-')
transport_table = doc.tables[1]
_set_docx_cell_text(transport_table, 1, 0, work_order.servicer_vehicle_make_model or '-')
_set_docx_cell_text(transport_table, 1, 1, work_order.servicer_vehicle_registration or '-')
_set_docx_cell_text(transport_table, 1, 2, '-')
_set_docx_cell_text(transport_table, 1, 4, completion_label)
all_task_records = [record for task in related_tasks for record in records_by_task.get(task.id, [])]
summary_table = doc.tables[2]
_docx_remove_rows_after(summary_table, keep_rows=1)
unique_parts_entries = _extract_unique_parts_entries(all_task_records)
summary_rows = unique_parts_entries or [{
'serial': '-',
'description': '-',
'note': '-',
'changed_at': '-',
}]
for index, entry in enumerate(summary_rows):
target_row = summary_table.rows[1] if index == 0 and len(summary_table.rows) > 1 else summary_table.add_row()
target_row.cells[0].text = entry['serial']
target_row.cells[1].text = entry['description']
target_row.cells[2].text = entry['note']
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, [])
if not task_records:
continue
repair_lines.append(f"Zadatak: {task.title or f'Servisni zadatak #{task.id}'}")
for record in task_records:
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, 1, 0, f"Popravak: {repair_text}")
else:
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."})
for paragraph in doc.paragraphs:
paragraph_text = str(getattr(paragraph, 'text', '') or '').strip()
if paragraph_text.lower().startswith('napomene:'):
paragraph.text = f"Napomene: {notes_text}"
break
hours_table = None
for table in doc.tables:
if not table.rows:
continue
headers = [_normalize_whitespace(cell.text).lower() for cell in table.rows[0].cells]
if len(headers) >= 9 and headers[0] == 'dan' and headers[1] == 'datum':
hours_table = table
break
if hours_table is None:
_docx_add_heading(doc, 'Tablica radnih sati', level=2)
hours_table = doc.add_table(rows=1, cols=9)
headers = ['Dan', 'Datum', 'Vrijeme rada', 'Vrijeme putovanja', 'Pauza h', 'Sati rada', 'Sati puta', 'Polazak / dolazak', 'Km vozila']
for index, header in enumerate(headers):
hours_table.rows[0].cells[index].text = header
_docx_remove_rows_after(hours_table, keep_rows=1)
if normalized_rows:
for row in normalized_rows:
cells = hours_table.add_row().cells
cells[0].text = row['day']
cells[1].text = row['date']
cells[2].text = row['work_time']
cells[3].text = row['travel_time']
cells[4].text = row['break_hours']
cells[5].text = row['work_hours']
cells[6].text = row['travel_hours']
cells[7].text = row['places']
cells[8].text = row['vehicle_km']
else:
cells = hours_table.add_row().cells
for index in range(9):
cells[index].text = '-'
_docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
_docx_cleanup_service_report_template(doc)
doc.add_page_break()
_docx_add_heading(doc, 'Servisni zapisi', level=2)
has_records = False
for task in related_tasks:
task_records = records_by_task.get(task.id, [])
if not task_records:
continue
has_records = True
_docx_add_heading(doc, task.title or f"Servisni zadatak #{task.id}", level=3)
for record in task_records:
doc.add_paragraph(f"Datum: {record.service_date or '-'}")
doc.add_paragraph(f"Opis: {record.description or '-'}")
doc.add_paragraph(f"Korišteni dijelovi: {record.parts or '-'}")
doc.add_paragraph(f"Trošak: {record.cost or '-'} EUR")
doc.add_paragraph(f"Kilometraža: {record.mileage if record.mileage is not None else '-'}")
doc.add_paragraph('')
if not has_records:
doc.add_paragraph('Nema povezanih servisnih zapisa za ovaj putni nalog.')
buffer = BytesIO()
doc.save(buffer)
return buffer.getvalue()
def _build_work_order_invoices_docx_bytes(work_order):
doc = _create_docx_document()
_docx_add_heading(doc, f'Računi putnog naloga {_work_order_display_code(work_order)}', level=1)
doc.add_paragraph(f'Datum generiranja: {timezone.localtime(timezone.now()).strftime("%d.%m.%Y %H:%M")}')
doc.add_paragraph(f'Klijent: {getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"}')
doc.add_paragraph(f'Dizalica: {work_order.vehicle.registration_number or "-"}')
invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at'))
table = doc.add_table(rows=1, cols=5)
table.rows[0].cells[0].text = 'Naziv računa'
table.rows[0].cells[1].text = 'Lokacija'
table.rows[0].cells[2].text = 'Datum'
table.rows[0].cells[3].text = 'Opis'
table.rows[0].cells[4].text = 'Prilog'
if invoices:
for invoice in invoices:
cells = table.add_row().cells
cells[0].text = str(invoice.naziv_racuna or '-')
cells[1].text = str(invoice.lokacija or '-')
cells[2].text = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'
cells[3].text = str(invoice.opis or '-')
cells[4].text = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '-'
else:
cells = table.add_row().cells
for index in range(5):
cells[index].text = '-'
additional_costs_table = getattr(work_order, 'additional_costs_table', None)
if additional_costs_table and isinstance(additional_costs_table.data, dict):
rows = additional_costs_table.data.get('rows', [])
if isinstance(rows, list) and rows:
doc.add_paragraph()
_docx_add_heading(doc, 'Dodatni troškovi', level=2)
costs_table = doc.add_table(rows=1, cols=3)
costs_table.rows[0].cells[0].text = 'Naziv'
costs_table.rows[0].cells[1].text = 'Broj računa'
costs_table.rows[0].cells[2].text = 'Ukupan iznos'
for row in rows:
if not isinstance(row, dict):
continue
cells = costs_table.add_row().cells
cells[0].text = str(row.get('naziv') or '-')
cells[1].text = str(row.get('broj_racuna') or '-')
cells[2].text = str(row.get('ukupan_iznos') or '-')
buffer = BytesIO()
doc.save(buffer)
return buffer.getvalue()
def _guess_content_type(filename): def _guess_content_type(filename):
mime, _ = mimetypes.guess_type(str(filename or '')) mime, _ = mimetypes.guess_type(str(filename or ''))
return mime or 'application/octet-stream' return mime or 'application/octet-stream'
@@ -1836,6 +2247,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"' response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"'
return response return response
@action(detail=True, methods=['get'], url_path='docx')
def docx(self, request, pk=None):
work_order = self.get_object()
docx_bytes = _build_work_order_docx_bytes(work_order)
response = HttpResponse(
docx_bytes,
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "work_order")}"'
return response
@action(detail=True, methods=['post'], url_path='pdf-request') @action(detail=True, methods=['post'], url_path='pdf-request')
def pdf_request(self, request, pk=None): def pdf_request(self, request, pk=None):
work_order = self.get_object() work_order = self.get_object()
@@ -1857,6 +2279,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"' response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"'
return response return response
@action(detail=True, methods=['get'], url_path='service-records-docx')
def service_records_docx(self, request, pk=None):
work_order = self.get_object()
docx_bytes = _build_work_order_service_records_docx_bytes(work_order)
response = HttpResponse(
docx_bytes,
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "service_records")}"'
return response
@action(detail=True, methods=['post'], url_path='service-records-pdf-request') @action(detail=True, methods=['post'], url_path='service-records-pdf-request')
def service_records_pdf_request(self, request, pk=None): def service_records_pdf_request(self, request, pk=None):
work_order = self.get_object() work_order = self.get_object()
@@ -1879,6 +2312,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response return response
@action(detail=True, methods=['get'], url_path='invoices-docx')
def invoices_docx(self, request, pk=None):
work_order = self.get_object()
docx_bytes = _build_work_order_invoices_docx_bytes(work_order)
response = HttpResponse(
docx_bytes,
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "invoices")}"'
return response
@action(detail=True, methods=['post'], url_path='invoices-pdf-request') @action(detail=True, methods=['post'], url_path='invoices-pdf-request')
def invoices_pdf_request(self, request, pk=None): def invoices_pdf_request(self, request, pk=None):
work_order = self.get_object() work_order = self.get_object()

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.15 on 2026-07-21 11:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('task_management', '0010_alter_tasktemplate_status'),
]
operations = [
migrations.CreateModel(
name='TaskCalendar',
fields=[
],
options={
'verbose_name': 'Kalendar zadatka',
'verbose_name_plural': 'Kalendar zadataka',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('task_management.task',),
),
]

View File

@@ -50,6 +50,7 @@ pytesseract==0.3.13
pytest>=9.1.1 pytest>=9.1.1
pytest-django>=4.12.0 pytest-django>=4.12.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-docx==1.2.0
python-dotenv==1.2.2 python-dotenv==1.2.2
pusher>=3.3.4 pusher>=3.3.4
redis==8.0.0 redis==8.0.0

View File

@@ -22,7 +22,8 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
command: npm run dev -- --host 0.0.0.0 --force working_dir: /app
command: sh -c "rm -rf /app/.astro /app/node_modules/.vite && npm run dev -- --host 0.0.0.0 --force"
ports: ports:
- "4321:4321" - "4321:4321"
volumes: volumes:
@@ -30,6 +31,9 @@ services:
- /app/node_modules - /app/node_modules
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- CHOKIDAR_USEPOLLING=true
- CHOKIDAR_INTERVAL=1000
- WATCHPACK_POLLING=true
- PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1 - PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1
- PUBLIC_HMR_HOST=localhost - PUBLIC_HMR_HOST=localhost
- PUBLIC_HMR_PROTOCOL=ws - PUBLIC_HMR_PROTOCOL=ws

View File

@@ -68,7 +68,12 @@ export default defineConfig({
}, },
watch: { watch: {
usePolling: true, usePolling: true,
interval: 100, interval: 1000,
awaitWriteFinish: {
stabilityThreshold: 1200,
pollInterval: 200,
},
ignored: ['**/.git/**', '**/node_modules/**', '**/.astro/**', '**/dist/**'],
}, },
} }
: { : {

View File

@@ -140,6 +140,9 @@ export function useAuthenticatedMediaSources(mediaPaths = []) {
if (!resolved) { if (!resolved) {
return ''; return '';
} }
if (isProtectedMediaUrl(resolved)) {
return authenticatedSources[resolved] || '';
}
return authenticatedSources[resolved] || resolved; return authenticatedSources[resolved] || resolved;
}; };

View File

@@ -1,22 +1,28 @@
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact'; import { useStore } from '@nanostores/preact';
import { import {
downloadWorkOrderDocx,
downloadWorkOrderPdf, downloadWorkOrderPdf,
downloadWorkOrderInvoicesDocx,
downloadWorkOrderInvoicesPdf, downloadWorkOrderInvoicesPdf,
downloadWorkOrderServiceRecordsDocx,
downloadWorkOrderServiceRecordsPdf, downloadWorkOrderServiceRecordsPdf,
fetchWorkOrderAdditionalCostsTable, fetchWorkOrderAdditionalCostsTable,
fetchWorkOrderById, fetchWorkOrderById,
fetchWorkOrderInvoices, fetchWorkOrderInvoices,
fetchWorkOrderTaskServiceContext, fetchWorkOrderTaskServiceContext,
updateTaskWorkHoursTable, updateTaskWorkHoursTable,
updateWorkOrder,
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 { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel'; import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal'; import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal'; import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
function readWorkOrderIdFromQuery() { function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -77,6 +83,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 [savingServiceReportNote, setSavingServiceReportNote] = useState(false);
const [serviceReportNoteError, setServiceReportNoteError] = useState('');
useEffect(() => { useEffect(() => {
hydrateAuthFromStorage(); hydrateAuthFromStorage();
@@ -207,6 +216,24 @@ 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">
@@ -225,6 +252,14 @@ export default function WorkOrderInvoicesPdfPage() {
> >
Preuzmi PDF putnog naloga Preuzmi PDF putnog naloga
</button> </button>
<button
type="button"
onClick={() => downloadWorkOrderDocx(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"
>
Preuzmi DOCX putnog naloga
</button>
</div> </div>
</div> </div>
@@ -244,6 +279,14 @@ export default function WorkOrderInvoicesPdfPage() {
> >
Uredi tablicu radnih sati Uredi tablicu radnih sati
</button> </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)}
@@ -252,6 +295,14 @@ export default function WorkOrderInvoicesPdfPage() {
> >
Preuzmi PDF servisnih zapisa Preuzmi PDF servisnih zapisa
</button> </button>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsDocx(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"
>
Preuzmi DOCX servisnih zapisa
</button>
</div> </div>
</div> </div>
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>} {loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
@@ -331,6 +382,14 @@ export default function WorkOrderInvoicesPdfPage() {
> >
Preuzmi račune putnog naloga Preuzmi račune putnog naloga
</button> </button>
<button
type="button"
onClick={() => downloadWorkOrderInvoicesDocx(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"
>
Preuzmi DOCX računa
</button>
</div> </div>
</div> </div>
{!loading && !error && ( {!loading && !error && (
@@ -385,6 +444,17 @@ export default function WorkOrderInvoicesPdfPage() {
onClose={() => setEditingAdditionalCosts(false)} onClose={() => setEditingAdditionalCosts(false)}
onSave={handleSaveAdditionalCostsTable} onSave={handleSaveAdditionalCostsTable}
/> />
<WorkOrderServiceNotesModal
open={editingServiceReportNote}
workOrder={workOrder}
saving={savingServiceReportNote}
error={serviceReportNoteError}
onClose={() => {
setEditingServiceReportNote(false);
setServiceReportNoteError('');
}}
onSave={handleSaveServiceReportNote}
/>
</section> </section>
); );
} }

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
export default function WorkOrderServiceNotesModal({
open,
workOrder,
saving = false,
error = '',
onClose,
onSave,
}) {
const [notes, setNotes] = useState('');
useEffect(() => {
if (!open) {
setNotes('');
return;
}
setNotes(String(workOrder?.notes || ''));
}, [open, workOrder?.id, workOrder?.notes]);
if (!open) return null;
const handleSave = async () => {
await onSave?.(notes);
};
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-2xl 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">
<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">UREDI TEKST NAPOMENE</h3>
<p className="text-xs text-text-muted">
Tekst se prikazuje u PDF-u "Izvještaj servisera" na prvoj stranici.
</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-2 px-5 py-4">
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
) : null}
<label htmlFor="work-order-notes" className="text-sm font-medium text-text-main">
Napomena
</label>
<textarea
id="work-order-notes"
value={notes}
onInput={(event) => setNotes(event.currentTarget.value)}
rows={8}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Unesite tekst napomene za PDF izvještaj servisera..."
/>
</div>
<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"
>
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"
>
{saving ? 'Spremam…' : 'Spremi napomenu'}
</button>
</div>
</div>
</ModalShell>
);
}

View File

@@ -257,6 +257,16 @@ function replaceWorkOrderItem(workOrderId, nextValue) {
); );
} }
function upsertWorkOrderItem(workOrderId, nextValue) {
const current = $workOrders.get();
const exists = current.some((item) => String(item.id) === String(workOrderId));
if (exists) {
replaceWorkOrderItem(workOrderId, nextValue);
return;
}
$workOrders.set([nextValue, ...current]);
}
function appendLocalDraft(entity, payload, tempId) { function appendLocalDraft(entity, payload, tempId) {
const base = { const base = {
...payload, ...payload,
@@ -547,15 +557,12 @@ export async function createWorkOrder(data) {
export async function updateWorkOrder(workOrderId, changes) { export async function updateWorkOrder(workOrderId, changes) {
const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId)); const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId));
if (!existing) {
throw new Error('Work order nije pronađen u lokalnom stanju.');
}
const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get(); const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
if (canSendNow) { if (canSendNow) {
try { try {
const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes); const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes);
replaceWorkOrderItem(workOrderId, response); upsertWorkOrderItem(workOrderId, response);
await persistDashboardCache(); await persistDashboardCache();
await logSync( await logSync(
{ entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' }, { entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
@@ -575,6 +582,10 @@ export async function updateWorkOrder(workOrderId, changes) {
} }
} }
if (!existing) {
throw new Error('Work order nije dostupan offline. Otvorite dashboard ili pokušajte ponovno kada ste online.');
}
const optimistic = { const optimistic = {
...existing, ...existing,
...changes, ...changes,
@@ -849,6 +860,51 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
return payload; return payload;
} }
export async function downloadWorkOrderDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order.docx`);
showToast('DOCX putnog naloga je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX putnog naloga nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
showToast('DOCX servisnih zapisa je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function downloadWorkOrderInvoicesDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.docx`);
showToast('DOCX računa putnog naloga je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX računa nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') { export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
if (!downloadUrl) { if (!downloadUrl) {
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error'); showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');