import { atom, computed } from 'nanostores'; import { api } from '../services/apiClient'; import { $accessToken } from './authStore'; import { showToast } from './toastStore'; import { $tasks } from './taskStore'; const DB_NAME = 'erp-offline-db'; const DB_VERSION = 1; const STORE_KV = 'kv'; const STORE_QUEUE = 'queue'; const STORE_SYNC_LOG = 'sync_log'; const CACHE_KEY = 'fleet_dashboard_cache'; const MAX_SYNC_LOG_ITEMS = 150; export const $dashboardLoading = atom(false); export const $dashboardError = atom(null); export const $cranes = atom([]); export const $vehicles = $cranes; export const $workOrders = atom([]); export const $serviceRecords = atom([]); export const $offlineQueueCount = atom(0); export const $syncLog = atom([]); // Timestamp posljednjeg uspješnog fetch-a — koristi se za TTL provjeru let _lastDashboardFetchAt = 0; const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi let dbPromise = null; let syncListenerStarted = false; let isSyncInProgress = false; function isBrowser() { return typeof window !== 'undefined'; } function nowIso() { return new Date().toISOString(); } function createTempId(prefix) { return `offline-${prefix}-${Date.now()}-${Math.floor(Math.random() * 100000)}`; } function saveBlobToFile(blob, filename) { if (!isBrowser()) { return; } const href = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = href; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(href); } function normalizeCraneList(items) { if (!Array.isArray(items)) { return []; } return items.filter((item) => { if (!item || typeof item !== 'object') return false; return !item.asset_type || item.asset_type === 'crane'; }); } function toSerializable(value) { return JSON.parse(JSON.stringify(value)); } function openDB() { if (!isBrowser()) { return Promise.reject(new Error('IndexedDB is unavailable on server')); } if (dbPromise) { return dbPromise; } dbPromise = new Promise((resolve, reject) => { const request = window.indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_KV)) { db.createObjectStore(STORE_KV, { keyPath: 'key' }); } if (!db.objectStoreNames.contains(STORE_QUEUE)) { db.createObjectStore(STORE_QUEUE, { keyPath: 'id', autoIncrement: true }); } if (!db.objectStoreNames.contains(STORE_SYNC_LOG)) { db.createObjectStore(STORE_SYNC_LOG, { keyPath: 'id', autoIncrement: true }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error('IndexedDB open failed')); }); return dbPromise; } function runTx(storeName, mode, action) { return openDB().then((db) => new Promise((resolve, reject) => { const tx = db.transaction(storeName, mode); const store = tx.objectStore(storeName); const result = action(store); tx.oncomplete = () => resolve(result); tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed')); tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted')); })); } function idbGetKV(key) { return openDB().then((db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_KV, 'readonly'); const store = tx.objectStore(STORE_KV); const request = store.get(key); request.onsuccess = () => resolve(request.result ? request.result.value : null); request.onerror = () => reject(request.error || new Error('IndexedDB get failed')); })); } function idbSetKV(key, value) { return runTx(STORE_KV, 'readwrite', (store) => { store.put({ key, value: toSerializable(value), updated_at: nowIso() }); }); } function idbAddQueueItem(item) { return openDB().then((db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_QUEUE, 'readwrite'); const store = tx.objectStore(STORE_QUEUE); const request = store.add(toSerializable(item)); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error('IndexedDB queue add failed')); })); } function idbGetQueueItems() { return openDB().then((db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_QUEUE, 'readonly'); const store = tx.objectStore(STORE_QUEUE); const request = store.getAll(); request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []); request.onerror = () => reject(request.error || new Error('IndexedDB queue read failed')); })); } function idbDeleteQueueItem(id) { return runTx(STORE_QUEUE, 'readwrite', (store) => { store.delete(id); }); } function idbPutQueueItem(item) { return runTx(STORE_QUEUE, 'readwrite', (store) => { store.put(toSerializable(item)); }); } function idbAddSyncLog(entry) { return openDB().then((db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_SYNC_LOG, 'readwrite'); const store = tx.objectStore(STORE_SYNC_LOG); store.add(toSerializable(entry)); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error || new Error('IndexedDB sync log add failed')); })); } function idbGetSyncLog() { return openDB().then((db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_SYNC_LOG, 'readonly'); const store = tx.objectStore(STORE_SYNC_LOG); const request = store.getAll(); request.onsuccess = () => { const rows = Array.isArray(request.result) ? request.result : []; rows.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')); resolve(rows.slice(0, MAX_SYNC_LOG_ITEMS)); }; request.onerror = () => reject(request.error || new Error('IndexedDB sync log read failed')); })); } async function trimSyncLogIfNeeded() { const rows = await idbGetSyncLog(); if (rows.length <= MAX_SYNC_LOG_ITEMS) { return; } const keepIds = new Set(rows.slice(0, MAX_SYNC_LOG_ITEMS).map((row) => row.id)); await runTx(STORE_SYNC_LOG, 'readwrite', (store) => { const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) return; if (!keepIds.has(cursor.value.id)) { cursor.delete(); } cursor.continue(); }; }); } async function refreshSyncLogStore() { try { $syncLog.set(await idbGetSyncLog()); } catch { $syncLog.set([]); } } async function updateQueueCount() { try { const queue = await idbGetQueueItems(); $offlineQueueCount.set(queue.length); } catch { $offlineQueueCount.set(0); } } async function persistDashboardCache() { await idbSetKV(CACHE_KEY, { vehicles: $vehicles.get(), workOrders: $workOrders.get(), serviceRecords: $serviceRecords.get(), cachedAt: nowIso(), }); } async function restoreDashboardCache() { try { const cached = await idbGetKV(CACHE_KEY); if (!cached) return false; $vehicles.set(Array.isArray(cached.vehicles) ? cached.vehicles : []); $workOrders.set(Array.isArray(cached.workOrders) ? cached.workOrders : []); $serviceRecords.set(Array.isArray(cached.serviceRecords) ? cached.serviceRecords : []); return true; } catch { return false; } } function replaceTempItem(entity, tempId, serverItem) { if (entity === 'work_orders') { $workOrders.set($workOrders.get().map((item) => (item.id === tempId ? serverItem : item))); } else if (entity === 'service_records') { $serviceRecords.set($serviceRecords.get().map((item) => (item.id === tempId ? serverItem : item))); } else if (entity === 'vehicles') { $vehicles.set($vehicles.get().map((item) => (item.id === tempId ? serverItem : item))); } } function replaceWorkOrderItem(workOrderId, nextValue) { $workOrders.set( $workOrders.get().map((item) => (String(item.id) === String(workOrderId) ? nextValue : item)) ); } 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) { const base = { ...payload, id: tempId, is_offline_draft: true, created_at: payload.created_at || nowIso(), }; if (entity === 'work_orders') { $workOrders.set([base, ...$workOrders.get()]); } else if (entity === 'service_records') { $serviceRecords.set([base, ...$serviceRecords.get()]); } else if (entity === 'vehicles') { $vehicles.set([base, ...$vehicles.get()]); } } function describeSyncAction(item) { if (item.entity === 'work_orders') return 'Putni nalog'; if (item.entity === 'service_records') return 'Servisni zapis'; if (item.entity === 'vehicles' || item.entity === 'cranes') return 'Dizalica'; return 'Zapis'; } async function logSync(item, status, detail = '') { await idbAddSyncLog({ entity: item.entity, endpoint: item.endpoint, method: item.method, status, detail, created_at: nowIso(), }); await trimSyncLogIfNeeded(); await refreshSyncLogStore(); } async function processOfflineQueue() { if (!isBrowser() || isSyncInProgress || !navigator.onLine || !$accessToken.get()) { return; } const queue = await idbGetQueueItems(); if (!queue.length) { await updateQueueCount(); return; } isSyncInProgress = true; for (const item of queue.sort((a, b) => a.id - b.id)) { try { let response = null; if (item.method === 'POST') { response = await api.post(item.endpoint, item.body); } else if (item.method === 'PATCH') { response = await api.patch(item.endpoint, item.body); } else if (item.method === 'PUT') { response = await api.put(item.endpoint, item.body); } if (response && item.tempId && item.entity) { replaceTempItem(item.entity, item.tempId, response); } else if (response && item.entity === 'work_orders' && item.targetId) { replaceWorkOrderItem(item.targetId, response); } await idbDeleteQueueItem(item.id); await logSync(item, 'success', `${describeSyncAction(item)} sinkroniziran.`); } catch (error) { if (error?.status === 401) { await logSync(item, 'failed', 'Sinkronizacija zaustavljena: potrebna prijava.'); break; } const detail = error?.message || 'Nepoznata greška sinkronizacije.'; await logSync(item, 'failed', detail); } } await persistDashboardCache(); await updateQueueCount(); isSyncInProgress = false; } function startOfflineSyncListeners() { if (!isBrowser() || syncListenerStarted) return; syncListenerStarted = true; updateQueueCount(); refreshSyncLogStore(); window.addEventListener('online', async () => { await processOfflineQueue(); await fetchFleetDashboardData({ silent: true }); }); } async function createWithOfflineFallback(entity, endpoint, body) { const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get(); if (canSendNow) { try { const response = await api.post(endpoint, body); if (entity === 'service_records') { try { const { fetchNotifications } = await import('./notificationStore.js'); await fetchNotifications(); } catch (notificationError) { console.warn('Neuspjelo osvježavanje notifikacija nakon kreiranja servisnog zapisa.', notificationError); } } await logSync( { entity, endpoint, method: 'POST' }, 'success', `${describeSyncAction({ entity })} poslan odmah (online).` ); return response; } catch (error) { if (error?.status && error.status !== 0 && error.status !== 503) { await logSync( { entity, endpoint, method: 'POST' }, 'failed', `Online slanje nije uspjelo: ${error.message || 'greška'}.` ); throw error; } } } const tempId = createTempId(entity); appendLocalDraft(entity, body, tempId); await idbAddQueueItem({ tempId, entity, endpoint, method: 'POST', body, queued_at: nowIso(), }); await updateQueueCount(); await persistDashboardCache(); await logSync( { entity, endpoint, method: 'POST' }, 'queued', `${describeSyncAction({ entity })} spremljen offline i čeka internet vezu.` ); showToast('Podatak je spremljen offline i sinkronizirat će se kad se veza vrati.', 'warning'); return { id: tempId, ...body, is_offline_draft: true }; } async function queueWorkOrderUpdate(workOrderId, body) { const queue = await idbGetQueueItems(); const queuedCreate = queue.find( (item) => item.entity === 'work_orders' && item.method === 'POST' && String(item.tempId) === String(workOrderId) ); if (queuedCreate) { queuedCreate.body = { ...queuedCreate.body, ...body, }; await idbPutQueueItem(queuedCreate); return; } await idbAddQueueItem({ entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH', body, targetId: workOrderId, queued_at: nowIso(), }); } export const $workOrdersWithVehicle = computed( [$workOrders, $vehicles], (workOrders, vehicles) => { const vehicleById = new Map(vehicles.map((vehicle) => [vehicle.id, vehicle])); return workOrders.map((workOrder) => ({ ...workOrder, vehicleInfo: vehicleById.get(workOrder.vehicle) || null, craneInfo: vehicleById.get(workOrder.crane || workOrder.vehicle) || null, })); } ); export const $dashboardStats = computed( [$workOrders, $serviceRecords, $tasks], (workOrders, serviceRecords, tasks) => { const today = new Date().toISOString().slice(0, 10); const openWorkOrders = workOrders.filter((workOrder) => workOrder.status !== 'closed').length; const closedWorkOrders = workOrders.filter((workOrder) => workOrder.status === 'closed').length; // Broji Taskove čiji je scheduled_date danas i status aktivan ili u tijeku const servicesToday = tasks.filter( (t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis') ).length; const totalWorkOrders = workOrders.length; // Aktivni taskovi bez dodijeljenog putnog naloga const tasksWithoutWorkOrder = tasks.filter( (t) => (t.status === 'aktivan' || t.status === 'servis' || t.status === 'spreman_za_zavrsetak') && !t.work_order ).length; return { openWorkOrders, closedWorkOrders, totalWorkOrders, servicesToday, tasksWithoutWorkOrder, }; } ); export async function ensureVehiclesCatalog() { if (!$accessToken.get()) { return $vehicles.get(); } if ($vehicles.get().length > 0) { return $vehicles.get(); } try { const vehicles = await api.get('fleet/cranes/'); $vehicles.set(normalizeCraneList(vehicles)); await persistDashboardCache(); return $vehicles.get(); } catch (error) { if (error?.status === 401) { $dashboardError.set('Sesija je istekla. Prijavite se ponovno.'); return $vehicles.get(); } throw error; } } export async function fetchFleetDashboardData(options = {}) { const { silent = false, force = false } = options; // Preskoči fetch ako su podaci svježi (unutar TTL), osim ako je force=true if (!force && $workOrders.get().length > 0 && Date.now() - _lastDashboardFetchAt < DASHBOARD_FETCH_TTL_MS) { return; } startOfflineSyncListeners(); if (!silent) $dashboardLoading.set(true); $dashboardError.set(null); const token = $accessToken.get(); if (!token) { await restoreDashboardCache(); await updateQueueCount(); if (!silent) $dashboardLoading.set(false); return; } try { const [workOrders, serviceRecords, vehicles] = await Promise.all([ api.get('fleet/work-orders/'), api.get('fleet/service-records/'), api.get('fleet/cranes/'), ]); $workOrders.set(Array.isArray(workOrders) ? workOrders : []); $serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []); $vehicles.set(normalizeCraneList(vehicles)); _lastDashboardFetchAt = Date.now(); await persistDashboardCache(); await processOfflineQueue(); } catch (error) { const cacheLoaded = await restoreDashboardCache(); if (error?.status === 401) { if (!cacheLoaded) { $dashboardError.set('Prijavite se za prikaz podataka.'); } } else if (!cacheLoaded) { $dashboardError.set('Neuspješno dohvaćanje dashboard podataka.'); showToast('Trenutno nema mreže. Prikazani su lokalno spremljeni podaci ako postoje.', 'warning'); } } finally { await updateQueueCount(); await refreshSyncLogStore(); if (!silent) $dashboardLoading.set(false); } } export async function createWorkOrder(data) { return createWithOfflineFallback('work_orders', 'fleet/work-orders/', data); } export async function updateWorkOrder(workOrderId, changes) { const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId)); const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get(); if (canSendNow) { try { const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes); upsertWorkOrderItem(workOrderId, response); await persistDashboardCache(); await logSync( { entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' }, 'success', 'Ažuriranje naloga poslano odmah (online).' ); return response; } catch (error) { if (error?.status && error.status !== 0 && error.status !== 503) { await logSync( { entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' }, 'failed', `Online update nije uspio: ${error.message || 'greška'}.` ); throw error; } } } if (!existing) { throw new Error('Work order nije dostupan offline. Otvorite dashboard ili pokušajte ponovno kada ste online.'); } const optimistic = { ...existing, ...changes, is_offline_draft: true, }; replaceWorkOrderItem(workOrderId, optimistic); await queueWorkOrderUpdate(workOrderId, changes); await updateQueueCount(); await persistDashboardCache(); await logSync( { entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' }, 'queued', 'Ažuriranje naloga spremljeno offline.' ); showToast('Ažuriranje naloga spremljeno offline i čeka sinkronizaciju.', 'warning'); return optimistic; } export async function createServiceRecord(data) { return createWithOfflineFallback('service_records', 'fleet/service-records/', data); } export async function updateServiceRecord(serviceRecordId, changes) { const response = await api.patch(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/`, changes); $serviceRecords.set( $serviceRecords.get().map((item) => ( String(item.id) === String(serviceRecordId) ? response : item )) ); await persistDashboardCache(); showToast('Servisni zapis je uspješno ažuriran.', 'success'); return response; } export async function uploadServicePhoto(serviceRecordId, imageFile, description = '', signal = null) { const formData = new FormData(); formData.append('service_record', serviceRecordId); formData.append('image', imageFile); if (description.trim()) { formData.append('description', description.trim()); } const options = signal ? { signal } : {}; const response = await api.post('fleet/service-photos/', formData, options); showToast('Fotografija je uspješno učitana.', 'success'); return response; } export async function uploadWorkOrderPhoto(workOrderId, imageFile, description = '', signal = null) { const formData = new FormData(); formData.append('image', imageFile); if (description.trim()) { formData.append('description', description.trim()); } const options = signal ? { signal } : {}; const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/images/`, formData, options); return response; } export async function uploadServiceAttachment(serviceRecordId, file, description = '', signal = null) { const formData = new FormData(); formData.append('service_record', serviceRecordId); formData.append('file', file); if (description.trim()) { formData.append('description', description.trim()); } const options = signal ? { signal } : {}; const response = await api.post('fleet/service-attachments/', formData, options); showToast('Datoteka je uspješno učitana.', 'success'); return response; } export async function fetchServiceRecordFiles(serviceRecordId) { if (!serviceRecordId) { return { photos: [], attachments: [] }; } const [photos, attachments] = await Promise.all([ api.get(`fleet/service-photos/?service_record_id=${encodeURIComponent(serviceRecordId)}`), api.get(`fleet/service-attachments/?service_record_id=${encodeURIComponent(serviceRecordId)}`), ]); const normalizedPhotos = (Array.isArray(photos) ? photos : []).map((photo) => { if (!photo?.id) { return photo; } const query = new URLSearchParams({ w: '1280', q: '75', fmt: 'webp', }); return { ...photo, optimized_url: `fleet/service-photos/${encodeURIComponent(photo.id)}/optimized/?${query.toString()}`, }; }); return { photos: normalizedPhotos, attachments: Array.isArray(attachments) ? attachments : [], }; } export async function fetchWorkOrderImages(workOrderId, options = {}) { if (!workOrderId) { return []; } const width = Number(options.w) > 0 ? Number(options.w) : 1280; const quality = Number(options.q) > 0 ? Number(options.q) : 75; const format = (options.fmt || 'webp').toLowerCase(); const query = new URLSearchParams({ w: String(width), q: String(quality), fmt: format, }); const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/images/?${query.toString()}`); return Array.isArray(payload?.images) ? payload.images : []; } export async function fetchWorkOrderInvoices(workOrderId) { if (!workOrderId) { return []; } const payload = await api.get(`fleet/work-order-invoices/?work_order_id=${encodeURIComponent(workOrderId)}`); return Array.isArray(payload) ? payload : []; } export async function fetchWorkOrderById(workOrderId) { if (!workOrderId) { return null; } return await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/`); } export async function fetchTasksByWorkOrder(workOrderId) { if (!workOrderId) { return []; } const data = await api.get(`tasks/tasks/?work_order=${encodeURIComponent(workOrderId)}`); return Array.isArray(data) ? data : (data?.results ?? []); } export async function fetchWorkOrderTaskServiceContext(workOrderId) { if (!workOrderId) { return { tasks: [], additional_costs_table: null }; } const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`); return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null }; } export async function fetchWorkOrderAdditionalCostsTable(workOrderId) { if (!workOrderId) { return { data: { rows: [] }, total_for_payout: '0.00' }; } const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`); return payload && typeof payload === 'object' ? payload : { data: { rows: [] }, total_for_payout: '0.00' }; } export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } const payload = await api.put( `fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`, data ?? { rows: [] }, ); showToast('Dodatni troškovi su uspješno spremljeni.', 'success'); return payload; } export async function updateTaskWorkHoursTable(taskId, data) { if (!taskId) { throw new Error('Task ID je obavezan.'); } const payload = await api.put(`tasks/tasks/${encodeURIComponent(taskId)}/work-hours-table/`, data ?? null); showToast('Tablica radnih sati je uspješno spremljena.', 'success'); return payload; } export async function 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) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } const formData = new FormData(); formData.append('work_order', workOrderId); formData.append('naziv_racuna', String(data.naziv_racuna || '').trim()); if (data.lokacija != null) { formData.append('lokacija', String(data.lokacija).trim()); } if (data.datum) { formData.append('datum', data.datum); } if (data.opis != null) { formData.append('opis', String(data.opis).trim()); } if (data.imageFile instanceof File) { formData.append('image', data.imageFile); } const options = signal ? { signal } : {}; try { const response = await api.post('fleet/work-order-invoices/', formData, options); showToast('Račun je uspješno spremljen.', 'success'); return response; } catch (error) { showToast(error?.message || 'Neuspješno spremanje računa.', 'error'); throw error; } } async function _refreshNotificationsAsync() { if (!isBrowser()) return; try { const { fetchNotifications } = await import('./notificationStore.js'); await fetchNotifications(); } catch (_) { // silent — ne blokiramo PDF flow zbog notifikacija } } // Polling za async PDF generiranje: osveži notifikacije na 5s, 20s i 60s // kako bi uhvatili Celery completion bez ovisnosti o Pusher konekciji. function _schedulePdfNotificationPolling() { if (!isBrowser()) return; [5000, 20000, 60000].forEach((delay) => { setTimeout(() => _refreshNotificationsAsync(), delay); }); } export async function downloadWorkOrderPdf(workOrderId) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } showToast('Kreiran je zahtjev za PDF putnog naloga.', 'info'); const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf-request/`, {}); if (payload?.status === 'ready' && payload?.download_url) { await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order.pdf`); showToast('PDF putnog naloga je preuzet iz cache-a.', 'success'); _refreshNotificationsAsync(); return payload; } showToast('PDF putnog naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); _schedulePdfNotificationPolling(); return payload; } export async function downloadWorkOrderInvoicesPdf(workOrderId) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } showToast('Kreiran je zahtjev za PDF računa putnog naloga.', 'info'); const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf-request/`, {}); if (payload?.status === 'ready' && payload?.download_url) { await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-invoices.pdf`); showToast('PDF računa putnog naloga je preuzet iz cache-a.', 'success'); _refreshNotificationsAsync(); return payload; } showToast('PDF računa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); _schedulePdfNotificationPolling(); return payload; } export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) { if (!workOrderId) { 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' }, ); const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId); const taskTitle = String(taskOptions?.taskTitle || '').trim() .replace(/\s+/g, '_') .replace(/[^A-Za-z0-9_-]+/g, '') .replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`; saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.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'); const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {}); if (payload?.status === 'ready' && payload?.download_url) { await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-service-records.pdf`); showToast('PDF servisnih zapisa je preuzet iz cache-a.', 'success'); _refreshNotificationsAsync(); return payload; } showToast('PDF servisnih zapisa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); _schedulePdfNotificationPolling(); 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, taskId = null, taskOptions = {}) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : ''; try { const blob = await api.get( `fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/${query}`, { responseType: 'blob' }, ); if (taskId) { const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId); const taskTitle = String(taskOptions?.taskTitle || '').trim() .replace(/\s+/g, '_') .replace(/[^A-Za-z0-9_-]+/g, '') .replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`; saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.docx`); showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success'); } else { 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 fetchMonthlyServicerEntries(year, month) { const payload = await api.get( `fleet/monthly-servicer-day-entries/?year=${year}&month=${month}` ); return Array.isArray(payload) ? payload : (payload?.results ?? []); } export async function fetchMonthlyCostInvoices(year, month) { const payload = await api.get( `fleet/work-order-invoices/?year=${year}&month=${month}` ); return Array.isArray(payload) ? payload : (payload?.results ?? []); } export async function upsertMonthlyServicerEntry(data = {}) { if (!data.entry_date) { throw new Error('Datum dnevnog unosa je obavezan.'); } if (!data.entry_type) { throw new Error('Vrsta dnevnog unosa je obavezna.'); } try { const response = await api.post('fleet/monthly-servicer-day-entries/', data); showToast('Dnevni unos je uspjesno spremljen.', 'success'); return response; } catch (err) { showToast(err?.message || 'Spremanje dnevnog unosa nije uspjelo.', 'error'); throw err; } } export async function downloadMonthlyServiserReport(year, month) { try { const blob = await api.get( `fleet/reports/monthly-servicer/?year=${year}&month=${month}`, { responseType: 'blob' }, ); saveBlobToFile(blob, `izvjestaj-servisera-${year}-${String(month).padStart(2, '0')}.docx`); showToast('Izvještaj servisera je preuzet.', 'success'); } catch (err) { showToast(err?.message || 'Preuzimanje izvještaja servisera nije uspjelo.', 'error'); throw err; } } export async function downloadMonthlyCostsReport(year, month) { try { const blob = await api.get( `fleet/reports/monthly-costs/?year=${year}&month=${month}`, { responseType: 'blob' }, ); saveBlobToFile(blob, `troskovi-servisera-${year}-${String(month).padStart(2, '0')}.docx`); showToast('Izvještaj troškova je preuzet.', 'success'); } catch (err) { showToast(err?.message || 'Preuzimanje izvještaja troškova nije uspjelo.', 'error'); throw err; } } export async function downloadMonthlyServiceTasksArchive(year, month) { try { const blob = await api.get( `fleet/reports/monthly-service-tasks-archive/?year=${year}&month=${month}`, { responseType: 'blob' }, ); saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-SN.zip`); showToast('ZIP pojedinačnih servisnih taskova je preuzet.', 'success'); } catch (err) { showToast(err?.message || 'Preuzimanje ZIP-a servisnih taskova nije uspjelo.', 'error'); throw err; } } export async function downloadMonthlyWorkOrdersArchive(year, month) { try { const blob = await api.get( `fleet/reports/monthly-work-orders-archive/?year=${year}&month=${month}`, { responseType: 'blob' }, ); saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-PN.zip`); showToast('ZIP putnih naloga i računa je preuzet.', 'success'); } catch (err) { showToast(err?.message || 'Preuzimanje ZIP-a putnih naloga nije uspjelo.', 'error'); throw err; } } export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') { if (!downloadUrl) { showToast('Nedostaje URL za preuzimanje PDF-a.', 'error'); throw new Error('Nedostaje URL za preuzimanje PDF-a.'); } try { const blob = await api.get(downloadUrl, { responseType: 'blob' }); saveBlobToFile(blob, filename); return true; } catch (err) { const msg = err?.message || 'Preuzimanje PDF-a nije uspjelo.'; showToast(msg, 'error'); throw err; } } export function openWorkOrderInvoicesPdfPage(workOrderId) { if (!isBrowser() || !workOrderId) { return; } const url = `/putni-nalozi/racuni?workOrderId=${encodeURIComponent(workOrderId)}`; window.open(url, '_blank', 'noopener,noreferrer'); } export async function fetchTeamMembers() { const members = await api.get('users/team-members/'); return Array.isArray(members) ? members : []; } export async function fetchServicers() { const members = await api.get('users/servicers/'); return Array.isArray(members) ? members : []; } export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {}) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } const recipients = Array.isArray(payload?.recipients) ? payload.recipients.filter(Boolean) : []; const normalizedPayload = { ...payload, recipients: recipients.length > 0 ? recipients : undefined, }; if (!normalizedPayload.recipients || normalizedPayload.recipients.length === 0) { delete normalizedPayload.recipients; } const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, normalizedPayload); if (options.toast !== false) { showToast('Zahtjev za slanje emaila je pokrenut u pozadini.', 'info'); } return response; } export async function downloadServiceRecordPdf(serviceRecordId) { if (!serviceRecordId) { throw new Error('Service record ID je obavezan.'); } const blob = await api.get(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/pdf/`, { responseType: 'blob' }); saveBlobToFile(blob, `${serviceRecordId}.service-record.pdf`); showToast('PDF servisnog zapisa je preuzet.', 'success'); } export async function sendServiceRecordEmail(serviceRecordId, payload = {}, options = {}) { if (!serviceRecordId) { throw new Error('Service record ID je obavezan.'); } const recipients = Array.isArray(payload?.recipients) ? payload.recipients.filter(Boolean) : []; if (recipients.length > 0) { const failures = []; for (const recipient of recipients) { try { await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, { ...payload, recipient, }); } catch (error) { failures.push({ recipient, error }); } } if (failures.length > 0) { const firstError = failures[0]?.error; const message = firstError?.message || `Neuspješno slanje za ${failures.length} primatelja.`; const batchError = new Error(message); batchError.failures = failures; throw batchError; } if (options.toast !== false) { showToast('Email za servisni zapis je poslan.', 'success'); } return { sent: true, recipients }; } const response = await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, payload); if (options.toast !== false) { showToast('Email za servisni zapis je poslan.', 'success'); } return response; } export async function createVehicle(data) { return createWithOfflineFallback('cranes', 'fleet/cranes/', data); } export async function fetchVehicleById(vehicleId) { if (!vehicleId) { return null; } return await api.get(`fleet/cranes/${encodeURIComponent(vehicleId)}/`); } export async function updateVehicle(vehicleId, changes) { if (!vehicleId) { throw new Error('ID dizalice je obavezan.'); } const existing = $vehicles.get().find((item) => String(item.id) === String(vehicleId)); if (!existing) { throw new Error('Dizalica nije pronađena u lokalnom stanju.'); } const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get(); if (!canSendNow) { throw new Error('Uređivanje podataka dizalice trenutno nije dostupno offline.'); } const response = await api.patch(`fleet/cranes/${encodeURIComponent(vehicleId)}/`, changes); $vehicles.set( $vehicles.get().map((item) => ( String(item.id) === String(vehicleId) ? response : item )) ); await persistDashboardCache(); return response; } export async function triggerOfflineSync() { await processOfflineQueue(); }