- 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>
851 lines
29 KiB
JavaScript
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();
|
|
}
|