This commit is contained in:
830
frontend/src/stores/fleetDashboardStore.js
Normal file
830
frontend/src/stores/fleetDashboardStore.js
Normal file
@@ -0,0 +1,830 @@
|
||||
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 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 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();
|
||||
}
|
||||
Reference in New Issue
Block a user