Files
004.erp.generic/frontend/src/stores/fleetDashboardStore.js
mariomitte 8e4b3f8aea
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
feat: WorkOrderPhoto izolacija, upload modal i SN stupac u tablici putnih naloga
- Dodan WorkOrderPhoto model (FK na WorkOrder, odvojen od VehicleServicePhoto)
- Migracija 0022_workorderphoto
- WorkOrderViewSet.images action refaktoriran: GET/POST koristi WorkOrderPhoto
- WorkOrderPhotoSerializer dodan u serializers.py
- WorkOrderPhotoInline dodan u admin.py
- Novi WorkOrderPhotoUpload.jsx modal s podrskom za onBack prop i z-[60]
- WorkOrderDetailModal: gumb Dodaj fotografije u editMode, photoUploadOpen state,
  ispravljen onSuccess callback (map na URL stringove), reset pri zatvaranju
- FleetDashboardShell: stupac SN u tablici putnih naloga (craneInfo.crane_serial_number)
- fleetDashboardStore: uklonjen dupli toast iz uploadWorkOrderPhoto
- Dodan test test_work_order_images_endpoint.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-16 06:33:08 +02:00

851 lines
29 KiB
JavaScript

import { atom, computed } from 'nanostores';
import { api } from '../services/apiClient';
import { $accessToken } from './authStore';
import { showToast } from './toastStore';
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([]);
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 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],
(workOrders, serviceRecords) => {
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;
const servicesToday = serviceRecords.filter((record) => record.service_date === today).length;
const warnings = serviceRecords.filter((record) => {
if (record.next_service_due_at == null || record.mileage == null) return false;
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
}).length;
return {
openWorkOrders,
closedWorkOrders,
servicesToday,
warnings,
};
}
);
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 } = options;
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));
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));
if (!existing) {
throw new Error('Work order nije pronađen u lokalnom stanju.');
}
const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
if (canSendNow) {
try {
const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes);
replaceWorkOrderItem(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;
}
}
}
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 fetchWorkOrderTaskServiceContext(workOrderId) {
if (!workOrderId) {
return { tasks: [] };
}
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
return payload && typeof payload === 'object' ? payload : { tasks: [] };
}
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 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 } : {};
const response = await api.post('fleet/work-order-invoices/', formData, options);
showToast('Račun je uspješno spremljen.', 'success');
return response;
}
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 blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order.pdf`);
showToast('PDF putnog naloga je preuzet.', 'success');
}
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 blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.pdf`);
showToast('PDF računa putnog naloga je preuzet.', 'success');
}
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.pdf`);
showToast('PDF servisnih zapisa putnog naloga je preuzet.', 'success');
}
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 sendWorkOrderEmail(workOrderId, payload = {}, options = {}) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, payload);
if (options.toast !== false) {
showToast('Email za putni nalog je poslan.', 'success');
}
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 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();
}