Files
004.erp.generic/frontend/src/stores/fleetDashboardStore.js
mariomitte 594881f6cd
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: update service report exports and calendar bulk downloads
Align service-record PDF/DOCX generation with task-level data and naming.

- Use Task.service_report_note as report note source
- Use Task.scheduled_date for service-record dates
- Rename per-task exports to MT...SN... format
- Add monthly SN/PN ZIP download endpoints with 7-day retention
- Add calendar 'Preuzmi sve' modal with both bulk download actions
- Prefill work-hours day/date from scheduled_date for empty tables
- Remove duplicate note table and clean extra DOCX page breaks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-06 10:32:03 +02:00

1191 lines
42 KiB
JavaScript

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();
}