Compare commits
2 Commits
908025abc6
...
ae6ae08fb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae6ae08fb1 | ||
|
|
1055820741 |
24
README.md
24
README.md
@@ -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/
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
BIN
backend/modules/fleet/docx_templates/Servisni N-R.docx
Normal file
BIN
backend/modules/fleet/docx_templates/Servisni N-R.docx
Normal file
Binary file not shown.
@@ -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()
|
||||||
|
|||||||
@@ -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',),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/**'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user