This commit is contained in:
32
frontend/.dockerignore
Normal file
32
frontend/.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# Izolacija paketa (Kritično)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Astro i Build cache (Sprječava prijenos starih lokalnih buildova)
|
||||
.astro/
|
||||
dist/
|
||||
|
||||
# Okruženje i Tajne (Sigurnost)
|
||||
.env
|
||||
.env.production
|
||||
.env.development
|
||||
.env.local
|
||||
*.env.bak
|
||||
|
||||
# Kontrola verzija i Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker higijena (Optimizacija cache slojeva)
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Operacijski sustav i Editori
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
20
frontend/Dockerfile
Normal file
20
frontend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Koristimo stabilnu Node.js sliku
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiramo datoteke koje definiraju pakete
|
||||
COPY package*.json ./
|
||||
|
||||
# Instaliramo ovisnosti (u dev modu Compose će ionako povući sve što treba)
|
||||
RUN npm install
|
||||
|
||||
# Kopiramo ostatak izvornog koda frontenda
|
||||
COPY . .
|
||||
|
||||
# Otvaramo port 4321 na kojem će se vrtjeti Astro
|
||||
EXPOSE 4321
|
||||
|
||||
# Zadana naredba (koju tvoj docker-compose.dev.yml ionako prepisuje,
|
||||
# ali je važno da je ovdje ispravan i čist fallback)
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "4321"]
|
||||
62
frontend/astro.config.mjs
Normal file
62
frontend/astro.config.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import preact from '@astrojs/preact';
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
|
||||
adapter: node({
|
||||
mode: 'standalone'
|
||||
}),
|
||||
|
||||
// 🛰️ Postavke razvojnog poslužitelja unutar Docker mreže
|
||||
server: {
|
||||
host: true, // Sluša na 0.0.0.0 unutar kontejnera
|
||||
port: 4321, // Unutarnji port kontejnera
|
||||
},
|
||||
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
react: 'preact/compat',
|
||||
'react-dom': 'preact/compat',
|
||||
'react-dom/test-utils': 'preact/test-utils',
|
||||
'react/jsx-runtime': 'preact/jsx-runtime',
|
||||
'react/jsx-dev-runtime': 'preact/jsx-dev-runtime',
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__PUSHER_KEY__: JSON.stringify(process.env.PUBLIC_PUSHER_KEY || process.env.PUSHER_KEY || ''),
|
||||
__PUSHER_CLUSTER__: JSON.stringify(process.env.PUBLIC_PUSHER_CLUSTER || process.env.PUSHER_CLUSTER || ''),
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['@react-spring/web', '@react-spring/core', '@react-spring/animated', '@react-spring/shared'],
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 1024,
|
||||
},
|
||||
plugins: [tailwindcss()],
|
||||
|
||||
// ⚙️ Ugrađene preporuke za Docker File Watching i Tihi Refresh (HMR)
|
||||
server: isDev ? {
|
||||
hmr: {
|
||||
protocol: 'ws',
|
||||
host: 'localhost',
|
||||
// Mora odgovarati mapiranom portu u docker-compose (4321:4321)
|
||||
clientPort: 4321,
|
||||
},
|
||||
watch: {
|
||||
// Prisiljava Vite na "polling" sustav jer Windows/WSL2 volumeni nekad blokiraju inotify signale
|
||||
usePolling: true,
|
||||
interval: 100,
|
||||
}
|
||||
} : {}, // U produkciji je ovaj blok potpuno prazan radi maksimalnih performansi
|
||||
},
|
||||
|
||||
// Redoslijed integracija: Tailwind pa Preact
|
||||
integrations: [preact()],
|
||||
});
|
||||
5977
frontend/package-lock.json
generated
Normal file
5977
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"engines": {
|
||||
"node": ">=22.12.0",
|
||||
"npm": ">=11.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^7.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.1.2",
|
||||
"@astrojs/preact": "^5.1.4",
|
||||
"@nanostores/preact": "^1.1.0",
|
||||
"@preact/signals": "^2.9.2",
|
||||
"@react-spring/web": "^10.1.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"astro": "^6.4.2",
|
||||
"flowbite": "^4.0.2",
|
||||
"idb": "^8.0.3",
|
||||
"nanostores": "^1.3.0",
|
||||
"preact": "^10.29.2",
|
||||
"pusher-js": "^8.4.0",
|
||||
"tailwindcss": "^4.3.0"
|
||||
}
|
||||
}
|
||||
19
frontend/public/manifest.webmanifest
Normal file
19
frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "ERP Generic",
|
||||
"short_name": "ERP",
|
||||
"description": "Offline-capable ERP dashboard for field service teams",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f9fafb",
|
||||
"theme_color": "#4f46e5",
|
||||
"lang": "hr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
53
frontend/public/offline.html
Normal file
53
frontend/public/offline.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Offline | ERP Generic</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.card {
|
||||
max-width: 560px;
|
||||
margin: 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
button {
|
||||
margin-top: 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Trenutno ste offline</h1>
|
||||
<p>
|
||||
Aplikacija i dalje radi s lokalno spremljenim podacima. Novi zapisi bit će sinkronizirani
|
||||
čim se internetska veza vrati.
|
||||
</p>
|
||||
<button onclick="location.reload()">Pokušaj ponovno</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
9
frontend/public/pwa-icon.svg
Normal file
9
frontend/public/pwa-icon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#4f46e5"/>
|
||||
<g fill="#ffffff">
|
||||
<rect x="106" y="126" width="300" height="70" rx="16"/>
|
||||
<rect x="106" y="221" width="300" height="70" rx="16" opacity="0.92"/>
|
||||
<rect x="106" y="316" width="190" height="70" rx="16" opacity="0.85"/>
|
||||
<circle cx="355" cy="351" r="36" fill="#fbbf24"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
62
frontend/public/service-worker.js
Normal file
62
frontend/public/service-worker.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const CACHE_NAME = 'erp-shell-v1';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cached = await caches.match(request);
|
||||
return cached || caches.match(OFFLINE_URL);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (['style', 'script', 'worker', 'font', 'image'].includes(request.destination)) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const networkFetch = fetch(request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached || networkFetch;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
61
frontend/src/components/AuthWidget.jsx
Normal file
61
frontend/src/components/AuthWidget.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { $accessToken, $authReady, hydrateAuthFromStorage, logout } from '../stores/authStore';
|
||||
import { showToast } from '../stores/toastStore';
|
||||
|
||||
export default function AuthWidget() {
|
||||
const token = useStore($accessToken);
|
||||
const authReady = useStore($authReady);
|
||||
// hasMounted: SSR uvijek renderira "Prijava" link — identičan SSR i client DOM
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
// AuthWidget je autonoman — ne oslanja se na to da netko drugi (FleetDashboardShell)
|
||||
// pozove hydrateAuthFromStorage(). Idempotentna je (authHydrated flag unutar store-a
|
||||
// osigurava da se localStorage čita samo jednom, čak i ako se poziva višestruko).
|
||||
hydrateAuthFromStorage();
|
||||
}, []);
|
||||
|
||||
// Dok SSR ili auth hydration nisu gotovi — prikaži neutralni "Prijava" link
|
||||
// kako ne bi došlo do layout shifta ili hydration mismatch-a
|
||||
if (!hasMounted || !authReady) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/login"
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Prijava
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{!token ? (
|
||||
<a
|
||||
href="/login"
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Prijava
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
logout();
|
||||
showToast('Uspješno ste odjavljeni.', 'success', 2500);
|
||||
window.location.href = '/login';
|
||||
}}
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Odjava
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/Navbar.jsx
Normal file
100
frontend/src/components/Navbar.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import NotificationBell from './NotificationBell';
|
||||
import ThemeToggle from './ui/ThemeToggle';
|
||||
import AuthWidget from './AuthWidget';
|
||||
import UserDisplay from './ui/UserDisplay';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { hydrateAuthFromStorage } from '../stores/authStore';
|
||||
|
||||
const ITEMS = [
|
||||
{ id: 'dashboard', label: 'Dashboard', href: '/' },
|
||||
{ id: 'work-orders', label: 'Putni nalozi', href: '/putni-nalozi' },
|
||||
{ id: 'service-records', label: 'Servisni zapisi', href: '/servisni-zapisi' },
|
||||
{ id: 'vehicles', label: 'Vozila', href: '/vehicles' },
|
||||
{ id: 'clients', label: 'Klijenti', href: '/clients' },
|
||||
];
|
||||
|
||||
export default function Navbar({ minimal = false }) {
|
||||
const [pathname, setPathname] = useState('/');
|
||||
const normalizedPath = useMemo(() => {
|
||||
const value = String(pathname || '/');
|
||||
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
|
||||
return value;
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const updatePath = () => setPathname(window.location.pathname || '/');
|
||||
updatePath();
|
||||
window.addEventListener('astro:page-load', updatePath);
|
||||
// Hidratira auth iz storage pri prvi puta učitavanja Navbar-a
|
||||
hydrateAuthFromStorage();
|
||||
return () => window.removeEventListener('astro:page-load', updatePath);
|
||||
}, []);
|
||||
|
||||
const currentItem = useMemo(
|
||||
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
|
||||
[normalizedPath]
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{/* Logo + korisnik */}
|
||||
<a href="/" className="flex flex-col hover:opacity-80">
|
||||
<span className="text-base font-bold tracking-tight text-text-main">ERP</span>
|
||||
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
|
||||
</a>
|
||||
|
||||
{/* Navigacijski linkovi */}
|
||||
<ul className="hidden items-center gap-1 text-sm sm:flex">
|
||||
{ITEMS.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={
|
||||
normalizedPath === item.href
|
||||
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
|
||||
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Desna strana */}
|
||||
<div className="flex items-center gap-2">
|
||||
<details className="relative sm:hidden">
|
||||
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
|
||||
{currentItem?.label || 'Izbornik'} ▾
|
||||
</summary>
|
||||
<div className="absolute right-0 mt-2 min-w-44 rounded-lg border border-border-hairline bg-canvas-elevated shadow-lg">
|
||||
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
|
||||
Navigacija
|
||||
</div>
|
||||
<ul className="py-1">
|
||||
{ITEMS.map((item) => (
|
||||
<li key={`mobile-${item.id}`}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={
|
||||
normalizedPath === item.href
|
||||
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
|
||||
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<ThemeToggle />
|
||||
<NotificationBell />
|
||||
<AuthWidget />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/NotificationBell.jsx
Normal file
135
frontend/src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import {
|
||||
$isRealtimeConnected,
|
||||
$notifications,
|
||||
$unreadNotifications,
|
||||
connectNotifications,
|
||||
fetchNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead,
|
||||
} from '../stores/notificationStore';
|
||||
import { $accessToken, $user } from '../stores/authStore';
|
||||
import NotificationDetailModal from './notifications/NotificationDetailModal';
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('hr-HR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedNotification, setSelectedNotification] = useState(null);
|
||||
const notifications = useStore($notifications);
|
||||
const unread = useStore($unreadNotifications);
|
||||
const isConnected = useStore($isRealtimeConnected);
|
||||
const token = useStore($accessToken);
|
||||
const user = useStore($user);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const latestNotifications = useMemo(
|
||||
() => notifications.slice(0, 6),
|
||||
[notifications]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user?.id) return;
|
||||
fetchNotifications();
|
||||
connectNotifications();
|
||||
}, [token, user?.id]);
|
||||
|
||||
// Stabilan placeholder za SSR + prvi client render.
|
||||
if (!mounted || !token) {
|
||||
return <div className="h-10 w-10" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border-hairline bg-canvas-base text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Notifikacije"
|
||||
>
|
||||
<span className="text-lg">🔔</span>
|
||||
{unread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-red-600 px-1 text-xs font-bold text-white">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-30 mt-2 w-96 overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold text-text-main">Notifikacije</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Realtime: {isConnected ? 'spojen' : 'nije spojen'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markAllNotificationsAsRead()}
|
||||
className="text-xs font-medium text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Označi sve
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="max-h-96 overflow-y-auto">
|
||||
{latestNotifications.length === 0 && (
|
||||
<li className="px-4 py-6 text-sm text-text-muted">Nema notifikacija.</li>
|
||||
)}
|
||||
{latestNotifications.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={`cursor-pointer border-b border-border-hairline px-4 py-3 last:border-b-0 ${
|
||||
notification.is_read ? 'bg-canvas-elevated' : 'bg-indigo-50/30'
|
||||
}`}
|
||||
onClick={async () => {
|
||||
if (!notification.is_read) {
|
||||
try {
|
||||
await markNotificationAsRead(notification.id);
|
||||
} catch {
|
||||
// Error handling already done in store toast.
|
||||
}
|
||||
}
|
||||
setSelectedNotification(notification);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-text-main">
|
||||
{notification.title}
|
||||
</p>
|
||||
<span className="shrink-0 text-[11px] text-text-muted">
|
||||
{formatTimestamp(notification.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-muted">{notification.message}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NotificationDetailModal
|
||||
open={!!selectedNotification}
|
||||
notification={selectedNotification}
|
||||
onClose={() => setSelectedNotification(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/ToastContainer.jsx
Normal file
50
frontend/src/components/ToastContainer.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { $toasts, clearToast } from '../stores/toastStore';
|
||||
|
||||
const TOAST_STYLES = {
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-800',
|
||||
error: 'border-red-200 bg-red-50 text-red-800',
|
||||
warning: 'border-amber-200 bg-amber-50 text-amber-800',
|
||||
info: 'border-sky-200 bg-sky-50 text-sky-800',
|
||||
};
|
||||
|
||||
export default function ToastContainer() {
|
||||
const toasts = useStore($toasts);
|
||||
const toastList = Array.isArray(toasts) ? toasts : [];
|
||||
|
||||
useEffect(() => {
|
||||
const timers = toastList.map((toast) =>
|
||||
window.setTimeout(() => clearToast(toast.id), toast.timeout || 4500)
|
||||
);
|
||||
return () => timers.forEach((timer) => window.clearTimeout(timer));
|
||||
}, [toastList]);
|
||||
|
||||
if (!toastList.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-[60] flex w-full max-w-sm flex-col gap-2">
|
||||
{toastList.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto rounded-lg border px-4 py-3 shadow-sm ${TOAST_STYLES[toast.type] || TOAST_STYLES.info}`}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm font-medium">{toast.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearToast(toast.id)}
|
||||
className="text-xs font-semibold opacity-75 hover:opacity-100"
|
||||
aria-label="Zatvori obavijest"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/ToastTrigger.jsx
Normal file
13
frontend/src/components/ToastTrigger.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { showToast } from '../stores/toastStore';
|
||||
|
||||
export default function ToastTrigger() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showToast('Sustav je spreman za rad.', 'info', 2500)}
|
||||
className="fixed bottom-4 right-4 z-50 rounded-full border border-border-hairline bg-canvas-elevated px-4 py-2 text-xs font-semibold text-text-main shadow-sm hover:bg-canvas-deep"
|
||||
>
|
||||
Test Toast
|
||||
</button>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/atom/ButtonDisplayCounter.jsx
Normal file
15
frontend/src/components/atom/ButtonDisplayCounter.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
export default function ButtonDisplayCounter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCount((value) => value + 1)}
|
||||
className="rounded-e-xl border border-border-hairline border-s-0 px-3 py-2 text-xs font-mono text-text-muted hover:bg-canvas-deep"
|
||||
>
|
||||
Clicks: {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/auth/LoginForm.jsx
Normal file
86
frontend/src/components/auth/LoginForm.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { $accessToken, $authReady, hydrateAuthFromStorage, loginWithCredentials } from '../../stores/authStore';
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
|
||||
export default function LoginForm() {
|
||||
const token = useStore($accessToken);
|
||||
const authReady = useStore($authReady);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
hydrateAuthFromStorage();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authReady && token) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, [authReady, token]);
|
||||
|
||||
if (token) return null;
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await loginWithCredentials(email.trim(), password);
|
||||
showToast('Uspješna prijava.', 'success', 2500);
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
const message = err?.message || 'Prijava nije uspjela.';
|
||||
setError(message);
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-text-main">Prijava</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">Prijavite se za pristup dashboardu.</p>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={submit}>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-text-main">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onInput={(event) => setEmail(event.currentTarget.value)}
|
||||
required
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-text-main">Lozinka</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onInput={(event) => setPassword(event.currentTarget.value)}
|
||||
required
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-lg bg-brand-primary px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'Prijava...' : 'Prijavi se'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/crm/ClientsSection.jsx
Normal file
171
frontend/src/components/crm/ClientsSection.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
// src/components/crm/ClientsSection.jsx
|
||||
// Prikaz liste klijenata i njihovih dizalica unutar dashboard sekcije.
|
||||
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { $clients, $clientsLoading, fetchClients } from '../../stores/clientStore';
|
||||
import { $cranes } from '../../stores/fleetDashboardStore';
|
||||
import AnimatedDataTable from '../ui/AnimatedDataTable';
|
||||
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
|
||||
export default function ClientsSection({ onSetServiceContext = null }) {
|
||||
const clients = useStore($clients);
|
||||
const loading = useStore($clientsLoading);
|
||||
const cranes = useStore($cranes);
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [selectedClientCraneId, setSelectedClientCraneId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMounted) return;
|
||||
if (clients.length === 0) fetchClients();
|
||||
}, [hasMounted, clients.length]);
|
||||
|
||||
function cranesForClient(clientId) {
|
||||
return cranes.filter((item) => String(item.client) === String(clientId));
|
||||
}
|
||||
|
||||
const clientTransitions = useTransition(clients, {
|
||||
keys: (client) => client.id,
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
||||
trail: 30,
|
||||
config: { tension: 230, friction: 26 },
|
||||
});
|
||||
|
||||
function handleSetServiceContext() {
|
||||
if (!selectedClientCraneId) {
|
||||
showToast('Prvo označite redak dizalice.', 'warning');
|
||||
return;
|
||||
}
|
||||
onSetServiceContext?.(selectedClientCraneId);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
||||
<h3 className="font-semibold text-text-main">Klijenti</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<ServiceRecordCreateButton
|
||||
onClick={handleSetServiceContext}
|
||||
label="Postavi servisni kontekst"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchClients()}
|
||||
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Osvježi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasMounted && (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">Učitavanje…</p>
|
||||
)}
|
||||
|
||||
{hasMounted && loading && (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">Učitavanje…</p>
|
||||
)}
|
||||
|
||||
{hasMounted && !loading && clients.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
Nema klijenata. Dodajte klijente u Django admin ili koristite CRM API.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMounted && !loading && clients.length > 0 && (
|
||||
<div className="divide-y divide-border-hairline">
|
||||
{clientTransitions((style, client) => {
|
||||
const cvs = cranesForClient(client.id);
|
||||
const isOpen = expandedId === client.id;
|
||||
const craneColumns = [
|
||||
{ key: 'registration', label: 'Oznaka', className: 'py-2 pr-4 text-left' },
|
||||
{ key: 'model', label: 'Make / Model', className: 'py-2 pr-4 text-left' },
|
||||
{ key: 'km', label: 'Km', className: 'py-2 pr-4 text-left' },
|
||||
{ key: 'nextService', label: 'Sljedeći servis', className: 'py-2 pr-4 text-left' },
|
||||
{ key: 'type', label: 'Vrsta', className: 'py-2 text-left' },
|
||||
];
|
||||
return (
|
||||
<animated.div key={client.id} style={style}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedId(isOpen ? null : client.id);
|
||||
setSelectedClientCraneId(null);
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-text-main">{client.name}</span>
|
||||
<span className="ml-2 text-xs text-text-muted">{client.tax_id}</span>
|
||||
<span className="ml-3 text-xs text-text-muted">{client.city}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
|
||||
{cvs.length} dizalica
|
||||
</span>
|
||||
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
|
||||
{cvs.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">
|
||||
Nema dizalica dodijeljenih ovom klijentu.
|
||||
Dodajte dizalicu u Django admin i povežite je s klijentom.
|
||||
</p>
|
||||
) : (
|
||||
<AnimatedDataTable
|
||||
columns={craneColumns}
|
||||
rows={cvs}
|
||||
rowKey={(item) => item.id}
|
||||
headClassName="text-xs uppercase tracking-wide text-text-muted"
|
||||
bodyClassName="divide-y divide-border-hairline"
|
||||
rowClassName="hover:bg-canvas-deep"
|
||||
tableClassName="min-w-full text-sm"
|
||||
wrapperClassName="overflow-x-auto"
|
||||
renderRow={(item) => {
|
||||
const isSelected = String(item.id) === String(selectedClientCraneId);
|
||||
const cellClassName = isSelected ? 'bg-emerald-50' : '';
|
||||
const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : '';
|
||||
return (
|
||||
<>
|
||||
<td className={`py-2 pr-4 font-medium cursor-pointer ${firstCellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
{item.service_interval_km
|
||||
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className={`py-2 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
<span className={item.is_company_vehicle
|
||||
? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700'
|
||||
: 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700'
|
||||
}>
|
||||
{item.is_company_vehicle ? 'Fleet' : 'Kupac'}
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
);}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/dashboard/DashboardTopbar.jsx
Normal file
83
frontend/src/components/dashboard/DashboardTopbar.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
import ServiceContextSelector from './ServiceContextSelector';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'work-orders', label: 'Putni nalozi' },
|
||||
{ key: 'service-records', label: 'Servisni zapisi' },
|
||||
{ key: 'cranes', label: 'Dizalice' },
|
||||
{ key: 'clients', label: 'Klijenti' },
|
||||
];
|
||||
|
||||
export default function DashboardTopbar({
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
onOpenWorkOrderModal,
|
||||
onOpenTaskModal,
|
||||
pendingSyncCount,
|
||||
onNewServiceRecord,
|
||||
showSectionNav = true,
|
||||
}) {
|
||||
const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
|
||||
{/* ── Gornji red ───────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p>
|
||||
<h2 className="text-xl font-semibold text-text-main">{sectionTitle}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{pendingSyncCount > 0 && (
|
||||
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
|
||||
Offline queue: {pendingSyncCount}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenWorkOrderModal}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
+ Novi putni nalog
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenTaskModal}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
+ Novi radni zadatak
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
|
||||
<ServiceContextSelector onNewServiceRecord={onNewServiceRecord} />
|
||||
|
||||
{/* ── Navigacijski tabovi ──────────────────────────────── */}
|
||||
{showSectionNav && (
|
||||
<nav className="overflow-x-auto border-t border-border-hairline px-4 sm:px-6">
|
||||
<ul className="flex min-w-max items-center gap-1 py-2 text-sm">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSectionChange(item.key);
|
||||
}}
|
||||
className={
|
||||
activeSection === item.key
|
||||
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
|
||||
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
1036
frontend/src/components/dashboard/FleetDashboardShell.jsx
Normal file
1036
frontend/src/components/dashboard/FleetDashboardShell.jsx
Normal file
File diff suppressed because it is too large
Load Diff
172
frontend/src/components/dashboard/ServiceContextSelector.jsx
Normal file
172
frontend/src/components/dashboard/ServiceContextSelector.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
// src/components/dashboard/ServiceContextSelector.jsx
|
||||
//
|
||||
// Persistentni selektor "Servisni kontekst" — odabir kupca + dizalice.
|
||||
// Stanje se čuva u localStorage i u serviceContextStore (reaktivno).
|
||||
//
|
||||
// USAGE:
|
||||
// <ServiceContextSelector onNewServiceRecord={({ craneId, clientId }) => ...} />
|
||||
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { $clients, fetchClients } from '../../stores/clientStore';
|
||||
import { $cranes } from '../../stores/fleetDashboardStore';
|
||||
import { $accessToken } from '../../stores/authStore';
|
||||
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
||||
import {
|
||||
$selectedClientId, $selectedVehicleId,
|
||||
hydrateServiceContext, setSelectedClient, setSelectedVehicle, clearServiceContext,
|
||||
} from '../../stores/serviceContextStore';
|
||||
|
||||
export default function ServiceContextSelector({ onNewServiceRecord }) {
|
||||
const clients = useStore($clients);
|
||||
const cranes = useStore($cranes);
|
||||
const accessToken = useStore($accessToken);
|
||||
const selectedClientId = useStore($selectedClientId);
|
||||
const selectedVehicleId = useStore($selectedVehicleId);
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
hydrateServiceContext();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
if (clients.length > 0) return;
|
||||
fetchClients();
|
||||
}, [accessToken, clients.length]);
|
||||
|
||||
const fallbackClients = Array.from(
|
||||
new Map(
|
||||
cranes
|
||||
.filter((item) => item?.client && item?.client_name)
|
||||
.map((item) => [String(item.client), { id: item.client, name: item.client_name }])
|
||||
).values()
|
||||
);
|
||||
const availableClients = clients.length > 0 ? clients : fallbackClients;
|
||||
|
||||
const clientCranes = cranes.filter((item) => String(item.client) === String(selectedClientId));
|
||||
const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
|
||||
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
|
||||
const showSelector = editMode || !selectedClientId;
|
||||
|
||||
function handleClientChange(e) {
|
||||
setSelectedClient(e.target.value);
|
||||
}
|
||||
|
||||
function handleVehicleChange(e) {
|
||||
setSelectedVehicle(e.target.value);
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
clearServiceContext();
|
||||
setEditMode(false);
|
||||
}
|
||||
|
||||
if (!hasMounted) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border-hairline bg-canvas-base/60 px-4 py-2 sm:px-6">
|
||||
{showSelector ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-text-muted">Servisni kontekst:</span>
|
||||
|
||||
<select
|
||||
value={selectedClientId ?? ''}
|
||||
onChange={handleClientChange}
|
||||
className="rounded-md border border-border-hairline bg-canvas-base px-2 py-1 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
>
|
||||
<option value="">— Odaberi kupca —</option>
|
||||
{availableClients.length === 0 && (
|
||||
<option value="" disabled>Nema dostupnih kupaca</option>
|
||||
)}
|
||||
{availableClients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{selectedClientId && (
|
||||
<select
|
||||
value={selectedVehicleId ?? ''}
|
||||
onChange={handleVehicleChange}
|
||||
className="rounded-md border border-border-hairline bg-canvas-base px-2 py-1 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
>
|
||||
<option value="">— Odaberi dizalicu —</option>
|
||||
{clientCranes.length > 0
|
||||
? clientCranes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.registration_number} {item.make} {item.model}
|
||||
</option>
|
||||
))
|
||||
: <option disabled>Nema dizalica za ovog kupca</option>
|
||||
}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedClientId && selectedVehicleId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(false)}
|
||||
className="rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700"
|
||||
>
|
||||
Potvrdi
|
||||
</button>
|
||||
)}
|
||||
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(false)}
|
||||
className="rounded-md border border-border-hairline px-3 py-1 text-xs text-text-muted hover:bg-canvas-deep"
|
||||
>
|
||||
Odustani
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs text-text-muted">Servisni kontekst:</span>
|
||||
|
||||
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
|
||||
{selectedClient?.name ?? '…'}
|
||||
</span>
|
||||
|
||||
{selectedCrane && (
|
||||
<>
|
||||
<span className="text-xs text-text-muted">/</span>
|
||||
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
|
||||
{selectedCrane.registration_number} {selectedCrane.make} {selectedCrane.model}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedVehicleId && onNewServiceRecord && (
|
||||
<ServiceRecordCreateButton
|
||||
onClick={() => {
|
||||
onNewServiceRecord({ craneId: selectedVehicleId, clientId: selectedClientId });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="rounded-md border border-border-hairline px-2 py-1 text-xs text-text-muted hover:bg-canvas-deep"
|
||||
>
|
||||
✎ Promijeni
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSelection}
|
||||
className="rounded-md border border-border-hairline px-2 py-1 text-xs text-text-muted hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/dashboard/SyncLogPanel.jsx
Normal file
57
frontend/src/components/dashboard/SyncLogPanel.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $syncLog } from '../../stores/fleetDashboardStore';
|
||||
import AnimatedDataTable from '../ui/AnimatedDataTable';
|
||||
|
||||
function rowStyle(status) {
|
||||
if (status === 'success') return 'text-emerald-700';
|
||||
if (status === 'failed') return 'text-red-700';
|
||||
return 'text-amber-700';
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return new Intl.DateTimeFormat('hr-HR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function SyncLogPanel() {
|
||||
const rows = useStore($syncLog);
|
||||
const syncColumns = [
|
||||
{ key: 'time', label: 'Vrijeme', className: 'px-4 py-2' },
|
||||
{ key: 'status', label: 'Status', className: 'px-4 py-2' },
|
||||
{ key: 'type', label: 'Tip', className: 'px-4 py-2' },
|
||||
{ key: 'detail', label: 'Detalj', className: 'px-4 py-2' },
|
||||
];
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
||||
<div className="border-b border-border-hairline px-4 py-3">
|
||||
<h3 className="font-semibold text-text-main">Status sinkronizacije</h3>
|
||||
<p className="text-xs text-text-muted">Što je poslano, što je ostalo u redu čekanja i što nije uspjelo.</p>
|
||||
</div>
|
||||
|
||||
<AnimatedDataTable
|
||||
columns={syncColumns}
|
||||
rows={rows}
|
||||
rowKey={(item) => item.id}
|
||||
emptyMessage="Još nema zapisa sinkronizacije."
|
||||
wrapperClassName="max-h-72 overflow-auto"
|
||||
renderRow={(item) => (
|
||||
<>
|
||||
<td className="px-4 py-2 text-text-muted">{formatDate(item.created_at)}</td>
|
||||
<td className={`px-4 py-2 font-semibold ${rowStyle(item.status)}`}>{item.status}</td>
|
||||
<td className="px-4 py-2 text-text-main">{item.entity}</td>
|
||||
<td className="px-4 py-2 text-text-muted">{item.detail || '-'}</td>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
188
frontend/src/components/dashboard/TaskCreateModal.jsx
Normal file
188
frontend/src/components/dashboard/TaskCreateModal.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { createTask, getStatusLabel } from '../../stores/taskStore';
|
||||
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
|
||||
|
||||
const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
|
||||
|
||||
const INITIAL_FORM = {
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'aktivan',
|
||||
work_order: '',
|
||||
};
|
||||
|
||||
export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
|
||||
const [form, setForm] = useState(INITIAL_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setForm(INITIAL_FORM);
|
||||
setSubmitting(false);
|
||||
setError('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function setField(name, value) {
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
|
||||
const title = String(form.title || '').trim();
|
||||
if (!title) {
|
||||
setError('Naslov zadatka je obavezan.');
|
||||
return;
|
||||
}
|
||||
if (form.status === 'neaktivan' && !form.work_order) {
|
||||
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
|
||||
return;
|
||||
}
|
||||
if (!contextCrane?.id) {
|
||||
setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTask({
|
||||
title,
|
||||
description: String(form.description || '').trim(),
|
||||
status: form.status || 'aktivan',
|
||||
vehicle: contextCrane.id,
|
||||
work_order: form.work_order || null,
|
||||
});
|
||||
await onSuccess?.();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Neuspješno kreiranje zadatka.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex min-h-full items-start justify-center">
|
||||
<div
|
||||
className="w-full max-w-xl max-h-[calc(100vh-2rem)] overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Novi radni zadatak</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori modal"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Naslov zadatka *</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onInput={(event) => setField('title', event.currentTarget.value)}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Opis</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onInput={(event) => setField('description', event.currentTarget.value)}
|
||||
disabled={submitting}
|
||||
className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Status</span>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(event) => setField('status', event.currentTarget.value)}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||
>
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{getStatusLabel(status)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Povezani putni nalog</span>
|
||||
<select
|
||||
value={form.work_order}
|
||||
onChange={(event) => setField('work_order', event.currentTarget.value)}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||
>
|
||||
<option value="">— Bez povezanog naloga —</option>
|
||||
{workOrders.map((workOrder) => (
|
||||
<option key={workOrder.id} value={String(workOrder.id)}>
|
||||
{formatEntityCode('PN', workOrder.id)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {formatPurposeLabel(workOrder.purpose) || '-'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{workOrders.length === 0 && (
|
||||
<span className="text-[11px] text-amber-600">
|
||||
Nema dostupnih naloga za odabranu dizalicu.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
disabled={submitting}
|
||||
>
|
||||
Odustani
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Spremanje...' : 'Kreiraj radni zadatak'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
frontend/src/components/dashboard/TaskServiceRecordsModal.jsx
Normal file
199
frontend/src/components/dashboard/TaskServiceRecordsModal.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import { formatEntityCode } from '../../lib/displayIds';
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleDateString('hr-HR');
|
||||
}
|
||||
|
||||
function formatCost(value) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) return '-';
|
||||
return `${numeric.toFixed(2)} EUR`;
|
||||
}
|
||||
|
||||
export default function TaskServiceRecordsModal({
|
||||
open,
|
||||
task,
|
||||
records = [],
|
||||
workOrders = [],
|
||||
onClose,
|
||||
onOpenServiceRecord,
|
||||
onAssignWorkOrder,
|
||||
onCreateServiceRecord,
|
||||
}) {
|
||||
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
|
||||
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
|
||||
const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
|
||||
const [assignError, setAssignError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof window === 'undefined') return;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!task) return;
|
||||
setSelectedWorkOrderId(task.work_order ? String(task.work_order) : '');
|
||||
setShowWorkOrderEditor(!task.work_order);
|
||||
setAssignError('');
|
||||
setAssigningWorkOrder(false);
|
||||
}, [task?.id, task?.work_order]);
|
||||
|
||||
const currentWorkOrder = useMemo(
|
||||
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
|
||||
[workOrders, task?.work_order, selectedWorkOrderId]
|
||||
);
|
||||
|
||||
const sortedWorkOrders = useMemo(
|
||||
() => [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))),
|
||||
[workOrders]
|
||||
);
|
||||
|
||||
const handleWorkOrderChange = async (event) => {
|
||||
const nextValue = String(event.currentTarget.value || '');
|
||||
setSelectedWorkOrderId(nextValue);
|
||||
setAssignError('');
|
||||
setAssigningWorkOrder(true);
|
||||
try {
|
||||
await onAssignWorkOrder?.(task, nextValue || null);
|
||||
setShowWorkOrderEditor(false);
|
||||
} catch (error) {
|
||||
setAssignError(error?.message || 'Neuspješno spremanje putnog naloga za radni zadatak.');
|
||||
} finally {
|
||||
setAssigningWorkOrder(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open || !task) return null;
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
|
||||
contentClassName="flex items-start justify-center min-h-full"
|
||||
panelClassName="w-full max-w-4xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl max-h-[calc(100vh-6rem)] overflow-hidden"
|
||||
>
|
||||
<div className="w-full flex flex-col h-full">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Radni zadatak detalji</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Radni zadatak: {task.title || '-'} • Status: {task.status || '-'} • Zapisa: {records.length}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni nalozi</h4>
|
||||
{currentWorkOrder && !showWorkOrderEditor ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded border border-border-hairline bg-canvas-elevated px-3 py-2">
|
||||
<p className="text-sm text-text-main">
|
||||
{formatEntityCode('PN', currentWorkOrder.id)} • {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} • {currentWorkOrder.status || '-'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWorkOrderEditor(true)}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
Izmijeni putni nalog
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={selectedWorkOrderId}
|
||||
onChange={handleWorkOrderChange}
|
||||
disabled={assigningWorkOrder}
|
||||
className="w-full rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main disabled:opacity-60"
|
||||
>
|
||||
<option value="">— Odaberi putni nalog —</option>
|
||||
{sortedWorkOrders.map((workOrder) => (
|
||||
<option key={workOrder.id} value={String(workOrder.id)}>
|
||||
{formatEntityCode('PN', workOrder.id)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {workOrder.status || '-'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-text-muted">
|
||||
{assigningWorkOrder ? 'Spremanje odabira putnog naloga...' : 'Odabir se automatski sprema u bazu.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{assignError && (
|
||||
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{assignError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold text-text-main">Povezani servisni zapisi</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreateServiceRecord?.(task)}
|
||||
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
+ Servisni zapis
|
||||
</button>
|
||||
</div>
|
||||
{records.length === 0 ? (
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-4 py-3 text-sm text-text-muted">
|
||||
Nema povezanih servisnih zapisa za odabrani radni zadatak.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border-hairline">
|
||||
<table className="w-full table-auto text-sm">
|
||||
<thead className="bg-canvas-base text-left text-text-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Datum</th>
|
||||
<th className="px-3 py-2">Opis</th>
|
||||
<th className="px-3 py-2">Trošak</th>
|
||||
<th className="px-3 py-2">Datoteke</th>
|
||||
<th className="px-3 py-2">Akcija</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => (
|
||||
<tr key={record.id} className="border-t border-border-hairline">
|
||||
<td className="px-3 py-2">{formatDate(record.service_date)}</td>
|
||||
<td className="max-w-[420px] truncate px-3 py-2" title={record.description || ''}>
|
||||
{record.description || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2">{formatCost(record.cost)}</td>
|
||||
<td className="px-3 py-2">{record.files_count ?? (record.photos_count ?? 0)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenServiceRecord?.(record)}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
Detalji
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
1115
frontend/src/components/dashboard/WorkOrderDetailModal.jsx
Normal file
1115
frontend/src/components/dashboard/WorkOrderDetailModal.jsx
Normal file
File diff suppressed because it is too large
Load Diff
152
frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
Normal file
152
frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
|
||||
function getBrowserApiBase() {
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
|
||||
if (typeof window === 'undefined') {
|
||||
return apiUrl || 'http://localhost:8001/api/';
|
||||
}
|
||||
|
||||
try {
|
||||
if (apiUrl) {
|
||||
const parsed = new URL(apiUrl);
|
||||
const internalHosts = new Set(['backend', 'localhost', '127.0.0.1']);
|
||||
const isInternalDockerHost = parsed.hostname === 'backend';
|
||||
if (!isInternalDockerHost) {
|
||||
return parsed.toString();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback is handled below
|
||||
}
|
||||
|
||||
return `${window.location.protocol}//${window.location.hostname}:8001/api/`;
|
||||
}
|
||||
|
||||
export function resolveMediaUrl(pathOrUrl) {
|
||||
if (!pathOrUrl || typeof window === 'undefined') return '';
|
||||
|
||||
let finalSrc = String(pathOrUrl);
|
||||
const apiUrl = getBrowserApiBase();
|
||||
const baseUrl = apiUrl.replace(/api\/?$/, '');
|
||||
|
||||
if (/^https?:\/\//i.test(finalSrc)) {
|
||||
if (finalSrc.includes('backend:8000')) {
|
||||
return finalSrc.replace('http://backend:8000/', baseUrl);
|
||||
}
|
||||
return finalSrc;
|
||||
}
|
||||
|
||||
if (finalSrc.startsWith('/')) {
|
||||
return `${baseUrl.replace(/\/$/, '')}${finalSrc}`;
|
||||
}
|
||||
|
||||
return new URL(finalSrc, apiUrl).toString();
|
||||
}
|
||||
|
||||
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
|
||||
const normalizedImages = useMemo(() => (
|
||||
Array.isArray(images)
|
||||
? images.filter(Boolean)
|
||||
: (typeof images === 'string' && images ? [images] : [])
|
||||
), [images]);
|
||||
const [currentImgIdx, setCurrentImgIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentImgIdx(0);
|
||||
}, [normalizedImages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode || normalizedImages.length <= 1) return undefined;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
|
||||
}, 3000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [normalizedImages.length, editMode]);
|
||||
|
||||
if (normalizedImages.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 w-full flex-col items-center justify-center border-b border-border-hairline bg-canvas-deep font-mono text-[11px] uppercase tracking-wider text-text-muted/40">
|
||||
<svg className="mb-2 h-8 w-8 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
</svg>
|
||||
{emptyLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const prevSlide = (event) => {
|
||||
event.preventDefault();
|
||||
setCurrentImgIdx((prev) => (prev - 1 + normalizedImages.length) % normalizedImages.length);
|
||||
};
|
||||
|
||||
const nextSlide = (event) => {
|
||||
event.preventDefault();
|
||||
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
|
||||
};
|
||||
|
||||
const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || '');
|
||||
const imageTransitions = useTransition(currentImgIdx, {
|
||||
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(-8%,0,0) scale(1.01)' },
|
||||
config: { tension: 220, friction: 26 },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="group relative h-72 w-full overflow-hidden border-b border-border-hairline bg-canvas-base sm:h-80">
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{imageTransitions((style, idx) => (
|
||||
<animated.img
|
||||
key={idx}
|
||||
src={resolveMediaUrl(normalizedImages[idx] || finalSrc)}
|
||||
alt={`Dokumentacija s terena ${idx + 1}`}
|
||||
style={style}
|
||||
className="absolute inset-0 h-full w-full object-cover will-change-transform"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
|
||||
{normalizedImages.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevSlide}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full border border-border-hairline bg-canvas-elevated/80 p-2 text-text-main opacity-0 shadow-lg transition-opacity duration-200 hover:bg-canvas-base group-hover:opacity-100"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextSlide}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full border border-border-hairline bg-canvas-elevated/80 p-2 text-text-main opacity-0 shadow-lg transition-opacity duration-200 hover:bg-canvas-base group-hover:opacity-100"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-3 z-10 flex justify-center gap-1.5">
|
||||
{normalizedImages.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setCurrentImgIdx(idx);
|
||||
}}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${idx === currentImgIdx ? 'w-4 bg-brand-accent' : 'w-1.5 bg-white/40 hover:bg-white/70'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
Normal file
258
frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import {
|
||||
downloadWorkOrderPdf,
|
||||
downloadWorkOrderInvoicesPdf,
|
||||
downloadWorkOrderServiceRecordsPdf,
|
||||
fetchWorkOrderById,
|
||||
fetchWorkOrderInvoices,
|
||||
fetchWorkOrderTaskServiceContext,
|
||||
} from '../../stores/fleetDashboardStore';
|
||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||
import { formatEntityCode } from '../../lib/displayIds';
|
||||
import { resolveMediaUrl } from './WorkOrderImageCarousel';
|
||||
|
||||
function readWorkOrderIdFromQuery() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('workOrderId') || '';
|
||||
}
|
||||
|
||||
function InvoiceImagePreview({ imageUrl, alt }) {
|
||||
const [broken, setBroken] = useState(false);
|
||||
|
||||
if (!imageUrl || broken) {
|
||||
return (
|
||||
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
|
||||
Bez slike
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="inline-flex">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
onError={() => setBroken(true)}
|
||||
className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkOrderInvoicesPdfPage() {
|
||||
const authReady = useStore($authReady);
|
||||
const token = useStore($accessToken);
|
||||
const [workOrderId, setWorkOrderId] = useState('');
|
||||
const [workOrder, setWorkOrder] = useState(null);
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [taskContext, setTaskContext] = useState({ tasks: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
hydrateAuthFromStorage();
|
||||
const id = readWorkOrderIdFromQuery();
|
||||
setWorkOrderId(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authReady) {
|
||||
fetchNotifications();
|
||||
connectNotifications();
|
||||
}
|
||||
}, [authReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workOrderId) {
|
||||
setLoading(false);
|
||||
setError('Nedostaje workOrderId u URL-u.');
|
||||
return;
|
||||
}
|
||||
if (!authReady) {
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
setError('Sesija nije aktivna. Prijavite se ponovno.');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [contextPayload, order, invoiceItems] = await Promise.all([
|
||||
fetchWorkOrderTaskServiceContext(workOrderId),
|
||||
fetchWorkOrderById(workOrderId),
|
||||
fetchWorkOrderInvoices(workOrderId),
|
||||
]);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setTaskContext(contextPayload);
|
||||
setWorkOrder(order);
|
||||
setInvoices(invoiceItems);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workOrderId, authReady, token]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!workOrder) {
|
||||
return 'Računi putnog naloga';
|
||||
}
|
||||
return `${formatEntityCode('PN', workOrder.id)}`;
|
||||
}, [workOrder]);
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-main">{title}</h1>
|
||||
<p className="text-sm text-text-muted">
|
||||
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderPdf(workOrderId)}
|
||||
disabled={!workOrderId}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
Preuzmi PDF putnog naloga
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-main">Task i povezani servisni zapisi</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
||||
disabled={!workOrderId}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
Preuzmi PDF servisnih zapisa
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
|
||||
{!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
|
||||
<p className="text-sm text-text-muted">Nema taskova povezanih s ovim putnim nalogom.</p>
|
||||
)}
|
||||
{!loading && !error && Array.isArray(taskContext.tasks) && taskContext.tasks.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{taskContext.tasks.map((task) => (
|
||||
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<div className="space-y-1 text-sm text-text-main">
|
||||
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
|
||||
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
|
||||
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
|
||||
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
|
||||
) : (
|
||||
task.service_records.map((record) => (
|
||||
<div key={record.id} className="rounded border border-border-hairline px-3 py-2">
|
||||
<div className="space-y-1 text-xs text-text-main">
|
||||
<div><span className="font-semibold">Servis:</span> {record.description || '-'}</div>
|
||||
<div><span className="font-semibold">Datum:</span> {record.service_date || '-'}</div>
|
||||
<div><span className="font-semibold">Trošak:</span> {record.cost || '-'} EUR</div>
|
||||
<div><span className="font-semibold">KM:</span> {record.mileage ?? '-'}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{Array.isArray(record.photos) && record.photos.length > 0 ? (
|
||||
record.photos.map((photo) => (
|
||||
<a
|
||||
key={photo.id}
|
||||
href={resolveMediaUrl(photo.optimized_url || photo.image_url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<img
|
||||
src={resolveMediaUrl(photo.optimized_url || photo.image_url)}
|
||||
alt={`Servisna slika ${photo.id}`}
|
||||
className="h-20 w-20 rounded border border-border-hairline object-cover"
|
||||
/>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">Bez slika</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
|
||||
disabled={!workOrderId}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
Preuzmi račune putnog naloga
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
|
||||
{!loading && error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && invoices.length === 0 && (
|
||||
<p className="text-sm text-text-muted">Za ovaj putni nalog nema unesenih računa.</p>
|
||||
)}
|
||||
{!loading && !error && invoices.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{invoices.map((invoice) => (
|
||||
<article key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
|
||||
<div className="space-y-1 text-sm text-text-main">
|
||||
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
|
||||
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
|
||||
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
|
||||
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
||||
</div>
|
||||
<InvoiceImagePreview
|
||||
imageUrl={resolveMediaUrl(invoice.image)}
|
||||
alt={`Račun ${invoice.naziv_racuna}`}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
592
frontend/src/components/dashboard/WorkOrderModal.jsx
Normal file
592
frontend/src/components/dashboard/WorkOrderModal.jsx
Normal file
@@ -0,0 +1,592 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import { $user } from '../../stores/authStore';
|
||||
|
||||
const PURPOSE_OPTIONS = [
|
||||
{ value: 'defektaza', label: 'Defektaža' },
|
||||
{ value: 'kontrola', label: 'Kontrola' },
|
||||
{ value: 'redovni_pregled', label: 'Redovni pregled' },
|
||||
];
|
||||
|
||||
const INITIAL_FORM = {
|
||||
has_travel_order: false,
|
||||
start_mileage: '',
|
||||
end_mileage: '',
|
||||
servicer_vehicle_registration: '',
|
||||
servicer_vehicle_make_model: '',
|
||||
servicer_vehicle_start_mileage: '',
|
||||
servicer_vehicle_end_mileage: '',
|
||||
servicer_vehicle_fuel_cost: '',
|
||||
location: '',
|
||||
travel_start_at: '',
|
||||
travel_end_at: '',
|
||||
purpose: '',
|
||||
notes: '',
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit }) {
|
||||
const user = useStore($user);
|
||||
const [form, setForm] = useState(INITIAL_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
||||
const [locationQuery, setLocationQuery] = useState('');
|
||||
const [locationResults, setLocationResults] = useState([]);
|
||||
const [selectedLocation, setSelectedLocation] = useState(null);
|
||||
const [searchingLocation, setSearchingLocation] = useState(false);
|
||||
const [mapError, setMapError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setForm(INITIAL_FORM);
|
||||
setSubmitting(false);
|
||||
setErrorMessage('');
|
||||
setMapPickerOpen(false);
|
||||
setLocationQuery('');
|
||||
setLocationResults([]);
|
||||
setSelectedLocation(null);
|
||||
setSearchingLocation(false);
|
||||
setMapError('');
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
start_mileage: selectedCrane ? String(selectedCrane.current_mileage ?? '') : prev.start_mileage,
|
||||
servicer_vehicle_registration: user?.assigned_vehicle?.registration_number || '',
|
||||
servicer_vehicle_make_model: `${user?.assigned_vehicle?.make || ''} ${user?.assigned_vehicle?.model || ''}`.trim(),
|
||||
servicer_vehicle_start_mileage: user?.assigned_vehicle?.current_mileage == null ? '' : String(user.assigned_vehicle.current_mileage),
|
||||
servicer_vehicle_end_mileage: '',
|
||||
servicer_vehicle_fuel_cost: '0.00',
|
||||
}));
|
||||
}, [open, selectedCrane, user?.assigned_vehicle?.id]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const selectedMapEmbedUrl = (() => {
|
||||
if (!selectedLocation) return null;
|
||||
const lat = Number(selectedLocation.lat);
|
||||
const lon = Number(selectedLocation.lon);
|
||||
if (Number.isNaN(lat) || Number.isNaN(lon)) return null;
|
||||
const delta = 0.01;
|
||||
const bbox = `${lon - delta}%2C${lat - delta}%2C${lon + delta}%2C${lat + delta}`;
|
||||
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat}%2C${lon}`;
|
||||
})();
|
||||
|
||||
const searchLocation = async () => {
|
||||
const query = locationQuery.trim();
|
||||
if (!query) {
|
||||
setMapError('Unesite naziv lokacije za pretragu.');
|
||||
return;
|
||||
}
|
||||
setMapError('');
|
||||
setSearchingLocation(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=8&q=${encodeURIComponent(query)}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Pretraga lokacije nije uspjela.');
|
||||
}
|
||||
const payload = await response.json();
|
||||
const results = Array.isArray(payload) ? payload : [];
|
||||
setLocationResults(results);
|
||||
if (results.length === 0) {
|
||||
setMapError('Lokacija nije pronađena. Pokušajte s detaljnijim nazivom.');
|
||||
}
|
||||
} catch (error) {
|
||||
setMapError(error?.message || 'Greška pri dohvaćanju karte.');
|
||||
} finally {
|
||||
setSearchingLocation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage('');
|
||||
|
||||
if (!selectedCrane) {
|
||||
setErrorMessage('Odaberite dizalicu u Servisnom kontekstu prije kreiranja naloga.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.purpose.trim()) {
|
||||
setErrorMessage('Svrha naloga je obavezna.');
|
||||
return;
|
||||
}
|
||||
|
||||
let startMileage = null;
|
||||
let endMileage = null;
|
||||
let servicerStartMileage = null;
|
||||
let servicerEndMileage = null;
|
||||
let servicerFuelCost = null;
|
||||
let travelStart = null;
|
||||
let travelEnd = null;
|
||||
|
||||
if (form.has_travel_order) {
|
||||
if (!form.start_mileage || !form.location.trim()) {
|
||||
setErrorMessage('Za putni nalog unesite početnu kilometražu i lokaciju.');
|
||||
return;
|
||||
}
|
||||
startMileage = Number(form.start_mileage);
|
||||
endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
|
||||
if (Number.isNaN(startMileage) || startMileage < 0) {
|
||||
setErrorMessage('Početna kilometraža mora biti valjan nenegativan broj.');
|
||||
return;
|
||||
}
|
||||
if (endMileage != null && (Number.isNaN(endMileage) || endMileage < startMileage)) {
|
||||
setErrorMessage('Završna kilometraža mora biti veća ili jednaka početnoj.');
|
||||
return;
|
||||
}
|
||||
if (!form.travel_start_at || !form.travel_end_at) {
|
||||
setErrorMessage('Unesite vrijeme početka i kraja puta.');
|
||||
return;
|
||||
}
|
||||
travelStart = new Date(form.travel_start_at);
|
||||
travelEnd = new Date(form.travel_end_at);
|
||||
if (Number.isNaN(travelStart.getTime()) || Number.isNaN(travelEnd.getTime())) {
|
||||
setErrorMessage('Vrijeme puta nije u ispravnom formatu.');
|
||||
return;
|
||||
}
|
||||
if (travelEnd < travelStart) {
|
||||
setErrorMessage('Vrijeme kraja puta ne može biti prije početka puta.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (form.servicer_vehicle_start_mileage !== '') {
|
||||
servicerStartMileage = Number(form.servicer_vehicle_start_mileage);
|
||||
if (Number.isNaN(servicerStartMileage) || servicerStartMileage < 0) {
|
||||
setErrorMessage('Početna kilometraža vozila servisera mora biti valjan nenegativan broj.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (form.servicer_vehicle_end_mileage !== '') {
|
||||
servicerEndMileage = Number(form.servicer_vehicle_end_mileage);
|
||||
if (Number.isNaN(servicerEndMileage) || servicerEndMileage < 0) {
|
||||
setErrorMessage('Završna kilometraža vozila servisera mora biti valjan nenegativan broj.');
|
||||
return;
|
||||
}
|
||||
if (servicerStartMileage == null) {
|
||||
setErrorMessage('Unesite početnu kilometražu vozila servisera ako unosite završnu.');
|
||||
return;
|
||||
}
|
||||
if (servicerEndMileage <= servicerStartMileage) {
|
||||
setErrorMessage('Završna kilometraža vozila servisera mora biti veća od početne.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (form.servicer_vehicle_fuel_cost !== '') {
|
||||
servicerFuelCost = Number(form.servicer_vehicle_fuel_cost);
|
||||
if (Number.isNaN(servicerFuelCost) || servicerFuelCost < 0) {
|
||||
setErrorMessage('Trošak goriva vozila servisera mora biti valjan nenegativan broj.');
|
||||
return;
|
||||
}
|
||||
servicerFuelCost = Number(servicerFuelCost.toFixed(2));
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
vehicle: selectedCrane.id,
|
||||
start_mileage: startMileage,
|
||||
end_mileage: endMileage,
|
||||
servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
|
||||
servicer_vehicle_make_model: form.servicer_vehicle_make_model.trim(),
|
||||
servicer_vehicle_start_mileage: servicerStartMileage,
|
||||
servicer_vehicle_end_mileage: servicerEndMileage,
|
||||
servicer_vehicle_fuel_cost: servicerFuelCost,
|
||||
location: form.has_travel_order ? form.location.trim() : '',
|
||||
travel_start_at: travelStart ? travelStart.toISOString() : null,
|
||||
travel_end_at: travelEnd ? travelEnd.toISOString() : null,
|
||||
purpose: form.purpose.trim(),
|
||||
notes: form.notes.trim(),
|
||||
status: form.status,
|
||||
});
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
|
||||
contentClassName="flex items-start justify-center min-h-full"
|
||||
panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
|
||||
>
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||
<h3 className="text-lg font-semibold text-text-main">Novi radni/putni nalog</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori modal"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Status *</span>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(event) => {
|
||||
const nextStatus = event.target.value;
|
||||
setForm({
|
||||
...form,
|
||||
status: nextStatus,
|
||||
end_mileage: nextStatus === 'open' ? '' : form.end_mileage,
|
||||
});
|
||||
}}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<h4 className="mb-3 text-sm font-semibold text-text-main">Podaci dizalice</h4>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Dizalica</span>
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main">
|
||||
{selectedCrane
|
||||
? (selectedCrane.registration_number || `Dizalica #${selectedCrane.id}`)
|
||||
: 'Nema odabrane dizalice u kontekstu'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Trenutna km dizalice</span>
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main">
|
||||
{selectedCrane?.current_mileage ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Početna kilometraža dizalice {form.has_travel_order ? '*' : ''}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.start_mileage}
|
||||
onInput={(event) => setForm({ ...form, start_mileage: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||
disabled={!form.has_travel_order}
|
||||
required={form.has_travel_order}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Završna kilometraža dizalice</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.end_mileage}
|
||||
onInput={(event) => setForm({ ...form, end_mileage: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||
disabled={!form.has_travel_order || form.status === 'open'}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<h4 className="mb-3 text-sm font-semibold text-text-main">Vozilo servisera (obračun troškova)</h4>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Registracija vozila servisera</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.servicer_vehicle_registration}
|
||||
onInput={(event) => setForm({ ...form, servicer_vehicle_registration: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||
placeholder="Npr. ZG-1234-AA"
|
||||
disabled
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Marka / model vozila servisera</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.servicer_vehicle_make_model}
|
||||
onInput={(event) => setForm({ ...form, servicer_vehicle_make_model: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||
placeholder="Npr. VW Caddy"
|
||||
disabled
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Početna kilometraža vozila servisera</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.servicer_vehicle_start_mileage}
|
||||
onInput={(event) => setForm({ ...form, servicer_vehicle_start_mileage: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Završna kilometraža vozila servisera</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.servicer_vehicle_end_mileage}
|
||||
onInput={(event) => setForm({ ...form, servicer_vehicle_end_mileage: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span className="font-medium text-text-main">Trošak goriva vozila servisera (EUR)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={form.servicer_vehicle_fuel_cost}
|
||||
onInput={(event) => setForm({ ...form, servicer_vehicle_fuel_cost: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||
disabled
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{!user?.assigned_vehicle && (
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
Korisnik nema dodijeljeno vozilo servisera, pa polja nisu popunjena.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.location}
|
||||
onInput={(event) => setForm({ ...form, location: event.currentTarget.value })}
|
||||
className="flex-1 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||
placeholder="Npr. Klijent Zagreb, servisna lokacija"
|
||||
disabled={!form.has_travel_order}
|
||||
required={form.has_travel_order}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocationQuery(form.location || '');
|
||||
setLocationResults([]);
|
||||
setSelectedLocation(null);
|
||||
setMapError('');
|
||||
setMapPickerOpen(true);
|
||||
}}
|
||||
disabled={!form.has_travel_order || submitting}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||
>
|
||||
Prikaži kartu
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Početak puta {form.has_travel_order ? '*' : ''}</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.travel_start_at}
|
||||
onInput={(event) => setForm({ ...form, travel_start_at: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||
disabled={!form.has_travel_order}
|
||||
required={form.has_travel_order}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Kraj puta {form.has_travel_order ? '*' : ''}</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.travel_end_at}
|
||||
onInput={(event) => setForm({ ...form, travel_end_at: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||
disabled={!form.has_travel_order}
|
||||
required={form.has_travel_order}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-text-main">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.has_travel_order}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
has_travel_order: checked,
|
||||
start_mileage: checked ? (prev.start_mileage || String(selectedCrane?.current_mileage ?? '')) : '',
|
||||
end_mileage: checked ? prev.end_mileage : '',
|
||||
location: checked ? prev.location : '',
|
||||
travel_start_at: checked ? prev.travel_start_at : '',
|
||||
travel_end_at: checked ? prev.travel_end_at : '',
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
Putni nalog uključuje putovanje (lokacija i vrijeme)
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Svrha naloga *</span>
|
||||
<select
|
||||
value={form.purpose}
|
||||
onChange={(event) => setForm({ ...form, purpose: event.currentTarget.value })}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||
required
|
||||
>
|
||||
<option value="">— Odaberi vrstu —</option>
|
||||
{PURPOSE_OPTIONS.map((purpose) => (
|
||||
<option key={purpose.value} value={purpose.value}>{purpose.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Napomena</span>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onInput={(event) => setForm({ ...form, notes: event.currentTarget.value })}
|
||||
className="min-h-24 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Odustani
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !selectedCrane}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'Spremam...' : 'Spremi nalog'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
{mapPickerOpen && (
|
||||
<ModalShell
|
||||
onClose={() => setMapPickerOpen(false)}
|
||||
overlayClassName="z-[60] overflow-y-auto p-4 pt-2"
|
||||
contentClassName="flex items-start justify-center min-h-full"
|
||||
panelClassName="w-full max-w-3xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||
<h4 className="text-base font-semibold text-text-main">Odabir lokacije na karti</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMapPickerOpen(false)}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori kartu"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={locationQuery}
|
||||
onInput={(event) => setLocationQuery(event.currentTarget.value)}
|
||||
className="flex-1 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||
placeholder="Upišite adresu ili naziv lokacije"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={searchLocation}
|
||||
disabled={searchingLocation}
|
||||
className="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{searchingLocation ? 'Tražim...' : 'Pretraži'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mapError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{mapError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{locationResults.length > 0 && (
|
||||
<div className="max-h-44 overflow-y-auto rounded-lg border border-border-hairline bg-canvas-base">
|
||||
{locationResults.map((result) => (
|
||||
<button
|
||||
key={result.place_id}
|
||||
type="button"
|
||||
onClick={() => setSelectedLocation(result)}
|
||||
className="block w-full border-b border-border-hairline px-3 py-2 text-left text-sm text-text-main hover:bg-canvas-deep last:border-b-0"
|
||||
>
|
||||
{result.display_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLocation && selectedMapEmbedUrl && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-main">
|
||||
Odabrano: <span className="font-medium">{selectedLocation.display_name}</span>
|
||||
</p>
|
||||
<iframe
|
||||
title="Karta odabrane lokacije"
|
||||
src={selectedMapEmbedUrl}
|
||||
className="h-72 w-full rounded-lg border border-border-hairline"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border-hairline px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMapPickerOpen(false)}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Odustani
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedLocation}
|
||||
onClick={() => {
|
||||
setForm((prev) => ({ ...prev, location: selectedLocation.display_name || prev.location }));
|
||||
setMapPickerOpen(false);
|
||||
}}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
Pohrani lokaciju
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
frontend/src/components/data/DataView.tsx
Normal file
114
frontend/src/components/data/DataView.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { fetchJson } from '../../lib/api';
|
||||
import { getCachedData, setCachedData } from '../../lib/db';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export interface DataViewProps {
|
||||
endpoint: string;
|
||||
cacheKey: string;
|
||||
initialData: unknown[] | null;
|
||||
initialError?: string | null;
|
||||
}
|
||||
|
||||
export default function DataView({
|
||||
endpoint,
|
||||
cacheKey,
|
||||
initialData,
|
||||
initialError = null,
|
||||
}: DataViewProps) {
|
||||
const toast = useToast();
|
||||
const itemsSignal = useSignal<unknown[]>([]);
|
||||
const stateSignal = useSignal<LoadState>('idle');
|
||||
const sourceSignal = useSignal<'ssr' | 'network' | 'cache' | null>(null);
|
||||
const errorSignal = useSignal<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(initialData) && initialData.length) {
|
||||
itemsSignal.value = initialData;
|
||||
stateSignal.value = 'success';
|
||||
sourceSignal.value = 'ssr';
|
||||
} else if (initialError) {
|
||||
errorSignal.value = initialError;
|
||||
stateSignal.value = 'error';
|
||||
} else {
|
||||
stateSignal.value = 'loading';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
// SSR gives first paint; hydration then refreshes client data and persists it offline.
|
||||
async function refreshData() {
|
||||
try {
|
||||
stateSignal.value = 'loading';
|
||||
const payload = await fetchJson<unknown[]>(endpoint, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
timeoutMs: 12000,
|
||||
});
|
||||
|
||||
itemsSignal.value = Array.isArray(payload) ? payload : [];
|
||||
await setCachedData(cacheKey, itemsSignal.value);
|
||||
sourceSignal.value = 'network';
|
||||
errorSignal.value = null;
|
||||
stateSignal.value = 'success';
|
||||
toast.success('Podaci su uspješno osvježeni.');
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = await getCachedData<unknown[]>(cacheKey);
|
||||
if (cached?.payload) {
|
||||
itemsSignal.value = Array.isArray(cached.payload) ? cached.payload : [];
|
||||
sourceSignal.value = 'cache';
|
||||
errorSignal.value = null;
|
||||
stateSignal.value = 'success';
|
||||
toast.warning('Prikazani su lokalno spremljeni podaci (offline fallback).');
|
||||
return;
|
||||
}
|
||||
|
||||
errorSignal.value = error instanceof Error ? error.message : 'Data loading failed.';
|
||||
stateSignal.value = 'error';
|
||||
toast.error('Neuspješno učitavanje podataka i nema lokalnog cachea.');
|
||||
}
|
||||
}
|
||||
|
||||
void refreshData();
|
||||
return () => controller.abort();
|
||||
}, [endpoint, cacheKey, initialData, initialError]);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border-hairline bg-canvas-elevated p-5 shadow-sm">
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-main">DataView</h2>
|
||||
<span className="text-xs text-text-muted">
|
||||
Source: {sourceSignal.value || 'unknown'} • State: {stateSignal.value}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{stateSignal.value === 'loading' && (
|
||||
<p className="text-sm text-text-muted">Učitavanje podataka...</p>
|
||||
)}
|
||||
|
||||
{stateSignal.value === 'error' && (
|
||||
<p className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{errorSignal.value || 'Greška pri učitavanju.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{stateSignal.value === 'success' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-muted">
|
||||
Ukupno zapisa: <strong className="text-text-main">{itemsSignal.value.length}</strong>
|
||||
</p>
|
||||
<pre className="max-h-96 overflow-auto rounded-lg border border-border-hairline bg-canvas-base p-3 text-xs text-text-main">
|
||||
{JSON.stringify(itemsSignal.value.slice(0, 20), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
247
frontend/src/components/fleet/ServicePhotoUpload.jsx
Normal file
247
frontend/src/components/fleet/ServicePhotoUpload.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
// src/components/fleet/ServicePhotoUpload.jsx
|
||||
// Forma za upload fotografija na servisni zapis (POST /api/fleet/service-photos/)
|
||||
// Kritično: AbortController prekida upload ako korisnik zatvori modal ili pokrene novi upload.
|
||||
// Backend prima multipart/form-data — apiClient detektira FormData i ne dodaje Content-Type header.
|
||||
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import { $isOffline } from '../../stores/networkStore.js';
|
||||
import { uploadServicePhoto } from '../../stores/fleetDashboardStore.js';
|
||||
import { showToast } from '../../stores/toastStore.js';
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
|
||||
|
||||
export default function ServicePhotoUpload({ open, serviceRecordId, onClose, onSuccess }) {
|
||||
const isOffline = useStore($isOffline);
|
||||
const [images, setImages] = useState([]);
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||
const [error, setError] = useState('');
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// AbortController ref — prekinuti tekući upload ako korisnik napusti ili pokrene novi
|
||||
const abortRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => {
|
||||
// Prekid uploadova pri unmount-u (navigacija, hot reload)
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setImages([]);
|
||||
setDescription('');
|
||||
setError('');
|
||||
setSubmitting(false);
|
||||
setProgress({ current: 0, total: 0 });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open || !isMounted) return null;
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES);
|
||||
const invalidType = files.filter((f) => !ACCEPTED_TYPES.includes(f.type));
|
||||
|
||||
if (oversized.length > 0) {
|
||||
setError(`Sljedeće datoteke prelaze ${MAX_FILE_SIZE_MB} MB: ${oversized.map((f) => f.name).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
if (invalidType.length > 0) {
|
||||
setError(`Nepodržan format: ${invalidType.map((f) => f.name).join(', ')}. Koristite JPG, PNG, WEBP ili HEIC.`);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setImages(files);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!serviceRecordId) {
|
||||
setError('Nije poznat servisni zapis za koji se upload vrši.');
|
||||
return;
|
||||
}
|
||||
if (images.length === 0) {
|
||||
setError('Odaberite barem jednu fotografiju.');
|
||||
return;
|
||||
}
|
||||
if (isOffline) {
|
||||
showToast('Upload fotografija nije dostupan u offline modu.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prekid prethodnog aktivnog uploadovaja (dupli klik zaštita)
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
const { signal } = abortRef.current;
|
||||
|
||||
setSubmitting(true);
|
||||
setProgress({ current: 0, total: images.length });
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
for (const [index, file] of images.entries()) {
|
||||
if (signal.aborted) break;
|
||||
setProgress({ current: index + 1, total: images.length });
|
||||
await uploadServicePhoto(serviceRecordId, file, description, signal);
|
||||
successCount++;
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
showToast(`${successCount} fotografija je uspješno uploadano.`, 'success');
|
||||
e.target.reset();
|
||||
setImages([]);
|
||||
setDescription('');
|
||||
onSuccess?.();
|
||||
onClose?.();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
// Tiho — korisnik je sam prekinuo, ne prikazujemo grešku
|
||||
return;
|
||||
}
|
||||
setError(err?.message || 'Greška pri uploadu fotografija.');
|
||||
if (successCount > 0) {
|
||||
showToast(`${successCount} od ${images.length} fotografija uploadano.`, 'warning');
|
||||
}
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setSubmitting(false);
|
||||
setProgress({ current: 0, total: 0 });
|
||||
abortRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
onClose={handleCancel}
|
||||
overlayClassName="z-50 p-4"
|
||||
contentClassName="flex items-center justify-center min-h-full"
|
||||
panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Upload fotografija</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Servisni zapis #{serviceRecordId}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted uppercase tracking-wide">
|
||||
Fotografije kvara / radova ({images.length} odabrano)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES.join(',')}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
disabled={submitting}
|
||||
className="block w-full text-xs text-text-muted file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-mono file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 file:cursor-pointer transition-all disabled:opacity-50"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-text-muted">
|
||||
JPG, PNG, WEBP, HEIC — max {MAX_FILE_SIZE_MB} MB po datoteci
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview odabranih datoteka */}
|
||||
{images.length > 0 && (
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-deep p-2 max-h-32 overflow-y-auto space-y-1">
|
||||
{images.map((file, i) => (
|
||||
<p key={i} className="text-[10px] font-mono text-text-muted truncate">
|
||||
📎 [{i + 1}] {file.name}{' '}
|
||||
<span className="text-text-muted/60">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Opis (primjenjuje se na sve slike)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onInput={(e) => setDescription(e.currentTarget.value)}
|
||||
disabled={submitting}
|
||||
placeholder="Npr. Oštećenje desnog prednjeg felge, kvar pumpe..."
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Traka napretka pri višestrukom uploadu */}
|
||||
{submitting && progress.total > 1 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-[11px] text-text-muted mb-1">
|
||||
<span>Uploading...</span>
|
||||
<span>{progress.current}/{progress.total}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-canvas-deep overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
{submitting ? 'Prekini upload' : 'Odustani'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || images.length === 0 || isOffline}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{submitting
|
||||
? `Uploading ${progress.current}/${progress.total}...`
|
||||
: `Upload ${images.length > 0 ? images.length + ' slike' : 'fotografija'}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/fleet/ServiceRecordCreateButton.jsx
Normal file
11
frontend/src/components/fleet/ServiceRecordCreateButton.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function ServiceRecordCreateButton({ onClick, className = '', label = '+ Servisni zapis' }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`btn-erp rounded-md bg-emerald-600 px-3 py-1 text-xs font-semibold normal-case tracking-normal text-white hover:bg-emerald-700 ${className}`.trim()}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
537
frontend/src/components/fleet/ServiceRecordDetailModal.jsx
Normal file
537
frontend/src/components/fleet/ServiceRecordDetailModal.jsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import {
|
||||
downloadServiceRecordPdf,
|
||||
fetchTeamMembers,
|
||||
fetchServiceRecordFiles,
|
||||
sendServiceRecordEmail,
|
||||
updateServiceRecord,
|
||||
} from '../../stores/fleetDashboardStore';
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import WorkOrderImageCarousel, { resolveMediaUrl } from '../dashboard/WorkOrderImageCarousel';
|
||||
import { formatEntityCode } from '../../lib/displayIds';
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleDateString('hr-HR');
|
||||
}
|
||||
|
||||
function formatCost(value) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) return '-';
|
||||
return `${numeric.toFixed(2)} EUR`;
|
||||
}
|
||||
|
||||
function resolveFileUrl(pathOrUrl) {
|
||||
if (!pathOrUrl) return '#';
|
||||
return resolveMediaUrl(pathOrUrl);
|
||||
}
|
||||
|
||||
export default function ServiceRecordDetailModal({
|
||||
open,
|
||||
serviceRecord,
|
||||
onClose,
|
||||
onUpdated,
|
||||
onBack = null,
|
||||
backLabel = 'Natrag',
|
||||
}) {
|
||||
const [files, setFiles] = useState({ photos: [], attachments: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [emailSending, setEmailSending] = useState(false);
|
||||
const [widgetError, setWidgetError] = useState('');
|
||||
const [emailModalOpen, setEmailModalOpen] = useState(false);
|
||||
const [teamMembers, setTeamMembers] = useState([]);
|
||||
const [loadingTeamMembers, setLoadingTeamMembers] = useState(false);
|
||||
const [selectedTeamMemberIds, setSelectedTeamMemberIds] = useState([]);
|
||||
const [sendToAllTeamMembers, setSendToAllTeamMembers] = useState(false);
|
||||
const [emailPayload, setEmailPayload] = useState({
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
service_title: '',
|
||||
description: '',
|
||||
parts: '',
|
||||
cost: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof window === 'undefined') return;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !serviceRecord?.id) {
|
||||
setFiles({ photos: [], attachments: [] });
|
||||
setError('');
|
||||
setLoading(false);
|
||||
setEmailSending(false);
|
||||
setWidgetError('');
|
||||
setEmailModalOpen(false);
|
||||
setTeamMembers([]);
|
||||
setLoadingTeamMembers(false);
|
||||
setSelectedTeamMemberIds([]);
|
||||
setSendToAllTeamMembers(false);
|
||||
setEmailPayload({ subject: '', message: '' });
|
||||
setEditMode(false);
|
||||
setEditSaving(false);
|
||||
setEditForm({ service_title: '', description: '', parts: '', cost: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setEditMode(false);
|
||||
setEditSaving(false);
|
||||
setEditForm({
|
||||
service_title: serviceRecord.service_title || '',
|
||||
description: serviceRecord.description || '',
|
||||
parts: serviceRecord.parts || '',
|
||||
cost: serviceRecord.cost == null ? '' : String(serviceRecord.cost),
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await fetchServiceRecordFiles(serviceRecord.id);
|
||||
if (!cancelled) {
|
||||
setFiles(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err?.message || 'Neuspješno dohvaćanje datoteka servisnog zapisa.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, serviceRecord?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emailModalOpen) return;
|
||||
let cancelled = false;
|
||||
setLoadingTeamMembers(true);
|
||||
(async () => {
|
||||
try {
|
||||
const members = await fetchTeamMembers();
|
||||
if (!cancelled) {
|
||||
setTeamMembers(members);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setWidgetError(err?.message || 'Neuspješno dohvaćanje članova tima.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingTeamMembers(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [emailModalOpen]);
|
||||
|
||||
const totalFiles = useMemo(
|
||||
() => (files.photos?.length || 0) + (files.attachments?.length || 0),
|
||||
[files]
|
||||
);
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
setWidgetError('');
|
||||
try {
|
||||
await downloadServiceRecordPdf(serviceRecord.id);
|
||||
} catch (err) {
|
||||
setWidgetError(err?.message || 'Neuspješno preuzimanje PDF-a servisnog zapisa.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
setWidgetError('');
|
||||
setEmailSending(true);
|
||||
try {
|
||||
const recipients = sendToAllTeamMembers
|
||||
? teamMembers.map((member) => member.email).filter(Boolean)
|
||||
: teamMembers
|
||||
.filter((member) => selectedTeamMemberIds.includes(String(member.id)))
|
||||
.map((member) => member.email)
|
||||
.filter(Boolean);
|
||||
if (recipients.length === 0) {
|
||||
setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
|
||||
return;
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
await sendServiceRecordEmail(
|
||||
serviceRecord.id,
|
||||
{
|
||||
recipient,
|
||||
subject: emailPayload.subject.trim() || undefined,
|
||||
message: emailPayload.message.trim() || undefined,
|
||||
},
|
||||
{ toast: false }
|
||||
);
|
||||
showToast(`Email je poslan: ${recipient}`, 'success');
|
||||
} catch {
|
||||
failed += 1;
|
||||
showToast(`Neuspješno slanje: ${recipient}`, 'error');
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
|
||||
return;
|
||||
}
|
||||
setEmailModalOpen(false);
|
||||
} catch (err) {
|
||||
setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
|
||||
} finally {
|
||||
setEmailSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
setWidgetError('');
|
||||
setEditSaving(true);
|
||||
try {
|
||||
const costValue = editForm.cost === '' ? '0.00' : Number(editForm.cost).toFixed(2);
|
||||
if (Number.isNaN(Number(costValue))) {
|
||||
setWidgetError('Trošak mora biti valjan decimalni broj.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateServiceRecord(serviceRecord.id, {
|
||||
service_title: editForm.service_title.trim(),
|
||||
description: editForm.description.trim(),
|
||||
parts: editForm.parts.trim(),
|
||||
cost: costValue,
|
||||
});
|
||||
setEditMode(false);
|
||||
onUpdated?.(updated);
|
||||
} catch (err) {
|
||||
setWidgetError(err?.message || 'Neuspješno ažuriranje servisnog zapisa.');
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open || !serviceRecord) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
|
||||
contentClassName="flex items-start justify-center min-h-full"
|
||||
panelClassName="flex w-full max-w-3xl flex-col rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-6rem)]"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Detalji servisnog zapisa</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
ID: {formatEntityCode('SR', serviceRecord.id)} • Datum: {formatDate(serviceRecord.service_date)} • Datoteke: {totalFiles}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={editMode || editSaving}
|
||||
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main">
|
||||
<p className="text-xs text-text-muted">Naziv servisnog zapisa</p>
|
||||
<p>{serviceRecord.service_title || '-'}</p>
|
||||
<p className="mt-1 text-xs text-text-muted">{serviceRecord.description || '-'}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main">
|
||||
<p className="text-xs text-text-muted">Trošak / KM / Dizalica</p>
|
||||
<p>
|
||||
{formatCost(serviceRecord.cost)} • {serviceRecord.mileage ?? '-'} km • {serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-text-main">Dokument i email</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadPdf}
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Preuzmi PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setWidgetError('');
|
||||
setEmailModalOpen(true);
|
||||
}}
|
||||
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700"
|
||||
>
|
||||
Pošalji email
|
||||
</button>
|
||||
</div>
|
||||
{widgetError && (
|
||||
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{widgetError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-3 text-sm text-text-muted">
|
||||
Učitavanje datoteka...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-lg border border-border-hairline bg-canvas-base">
|
||||
<WorkOrderImageCarousel
|
||||
images={files.photos.map((item) => item.optimized_url || item.image).filter(Boolean)}
|
||||
emptyLabel="Nema slika za ovaj servisni zapis"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-text-main">Dokumenti / prilozi</h4>
|
||||
{files.attachments.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">Nema priloga.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{files.attachments.map((item) => (
|
||||
<li key={item.id} className="text-sm">
|
||||
<a
|
||||
href={resolveFileUrl(item.file)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:text-indigo-800 hover:underline"
|
||||
>
|
||||
📎 {item.file?.split('/').pop() || `Prilog ${item.id}`}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{editMode && (
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-text-main">Uredi servisni zapis</h4>
|
||||
<div className="space-y-3">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Naziv servisnog zapisa</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.service_title}
|
||||
onInput={(event) => setEditForm((prev) => ({ ...prev, service_title: event.currentTarget.value }))}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Opis</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={editForm.description}
|
||||
onInput={(event) => setEditForm((prev) => ({ ...prev, description: event.currentTarget.value }))}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Korišteni dijelovi</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={editForm.parts}
|
||||
onInput={(event) => setEditForm((prev) => ({ ...prev, parts: event.currentTarget.value }))}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Trošak (EUR)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={editForm.cost}
|
||||
onInput={(event) => setEditForm((prev) => ({ ...prev, cost: event.currentTarget.value }))}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border-hairline px-5 py-3">
|
||||
{!editMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Uredi servisni zapis
|
||||
</button>
|
||||
)}
|
||||
{editMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
setEditForm({
|
||||
service_title: serviceRecord.service_title || '',
|
||||
description: serviceRecord.description || '',
|
||||
parts: serviceRecord.parts || '',
|
||||
cost: serviceRecord.cost == null ? '' : String(serviceRecord.cost),
|
||||
});
|
||||
}}
|
||||
disabled={editSaving}
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||
>
|
||||
Odustani
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={editSaving}
|
||||
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{editSaving ? 'Spremam…' : 'Spremi izmjene'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Zatvori
|
||||
</button>
|
||||
</div>
|
||||
</ModalShell>
|
||||
{emailModalOpen && (
|
||||
<ModalShell
|
||||
onClose={() => setEmailModalOpen(false)}
|
||||
overlayClassName="z-[60] p-4"
|
||||
contentClassName="flex items-center justify-center min-h-full"
|
||||
panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
||||
<h4 className="text-sm font-semibold text-text-main">Pošalji email za servisni zapis</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailModalOpen(false)}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori email modal"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-4 py-3">
|
||||
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendToAllTeamMembers}
|
||||
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
|
||||
/>
|
||||
Pošalji svim članovima tima
|
||||
</label>
|
||||
<select
|
||||
multiple
|
||||
disabled={sendToAllTeamMembers || loadingTeamMembers}
|
||||
value={selectedTeamMemberIds}
|
||||
onChange={(event) => {
|
||||
const nextIds = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
|
||||
setSelectedTeamMemberIds(nextIds);
|
||||
}}
|
||||
className="min-h-28 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
|
||||
>
|
||||
{!loadingTeamMembers && teamMembers.length === 0 && (
|
||||
<option disabled>Nema dostupnih članova tima</option>
|
||||
)}
|
||||
{teamMembers.map((member) => (
|
||||
<option key={member.id} value={String(member.id)}>
|
||||
{member.full_name} ({member.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-text-muted">
|
||||
{loadingTeamMembers
|
||||
? 'Učitavanje članova tima...'
|
||||
: `Odabrano: ${sendToAllTeamMembers ? teamMembers.length : selectedTeamMemberIds.length}`}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={emailPayload.subject}
|
||||
onInput={(event) => setEmailPayload({ ...emailPayload, subject: event.currentTarget.value })}
|
||||
placeholder="Naslov (opcionalno)"
|
||||
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
|
||||
/>
|
||||
<textarea
|
||||
value={emailPayload.message}
|
||||
onInput={(event) => setEmailPayload({ ...emailPayload, message: event.currentTarget.value })}
|
||||
placeholder="Poruka (opcionalno)"
|
||||
className="min-h-24 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailModalOpen(false)}
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Odustani
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendEmail}
|
||||
disabled={emailSending}
|
||||
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{emailSending ? 'Šaljem…' : 'Pošalji'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
374
frontend/src/components/fleet/ServiceRecordForm.jsx
Normal file
374
frontend/src/components/fleet/ServiceRecordForm.jsx
Normal file
@@ -0,0 +1,374 @@
|
||||
// src/components/fleet/ServiceRecordForm.jsx
|
||||
// Forma za kreiranje novog servisnog zapisa (POST /api/fleet/service-records/)
|
||||
// Koristi offline-first pattern iz fleetDashboardStore: createServiceRecord().
|
||||
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import { $isOffline } from '../../stores/networkStore.js';
|
||||
import { createServiceRecord, uploadServicePhoto, uploadServiceAttachment } from '../../stores/fleetDashboardStore.js';
|
||||
import { showToast } from '../../stores/toastStore.js';
|
||||
|
||||
const INITIAL = {
|
||||
task: '',
|
||||
description: '',
|
||||
parts: '',
|
||||
cost: '',
|
||||
};
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
|
||||
const ACCEPTED_DOCUMENT_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'application/zip',
|
||||
];
|
||||
const ACCEPTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt', '.zip'];
|
||||
|
||||
export default function ServiceRecordForm({
|
||||
open,
|
||||
assignedCrane,
|
||||
availableTasks = [],
|
||||
onClose,
|
||||
onSuccess,
|
||||
onBack = null,
|
||||
backLabel = 'Natrag',
|
||||
}) {
|
||||
const isOffline = useStore($isOffline);
|
||||
const [form, setForm] = useState(INITIAL);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [attachments, setAttachments] = useState([]);
|
||||
const [attachmentDescription, setAttachmentDescription] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState({ current: 0, total: 0 });
|
||||
const uploadAbortRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof window === 'undefined') return;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setForm(INITIAL);
|
||||
setError('');
|
||||
setSubmitting(false);
|
||||
setAttachments([]);
|
||||
setAttachmentDescription('');
|
||||
setUploadProgress({ current: 0, total: 0 });
|
||||
uploadAbortRef.current?.abort();
|
||||
uploadAbortRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Predaberi jedini dostupni task ako postoji samo jedan
|
||||
if (availableTasks.length === 1) {
|
||||
setForm((prev) => ({ ...prev, task: String(availableTasks[0].id) }));
|
||||
}
|
||||
}, [open, assignedCrane, availableTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.task) return;
|
||||
const exists = availableTasks.some((task) => String(task.id) === String(form.task));
|
||||
if (!exists) {
|
||||
setForm((prev) => ({ ...prev, task: '' }));
|
||||
}
|
||||
}, [availableTasks, form.task]);
|
||||
|
||||
if (!open || !isMounted) return null;
|
||||
|
||||
const field = (name) => (e) => setForm((prev) => ({ ...prev, [name]: e.currentTarget.value }));
|
||||
|
||||
const handleFileChange = (event) => { const files = Array.from(event.currentTarget.files ?? []);
|
||||
if (files.length === 0) {
|
||||
setAttachments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const oversized = files.filter((file) => file.size > MAX_FILE_SIZE_BYTES);
|
||||
if (oversized.length > 0) {
|
||||
setError(`Datoteka prelazi ${MAX_FILE_SIZE_MB} MB: ${oversized[0].name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidType = files.filter((file) => {
|
||||
const lowerName = String(file.name || '').toLowerCase();
|
||||
const byMime = ACCEPTED_IMAGE_TYPES.includes(file.type) || ACCEPTED_DOCUMENT_TYPES.includes(file.type);
|
||||
const byExtension = ACCEPTED_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
|
||||
return !(byMime || byExtension);
|
||||
});
|
||||
if (invalidType.length > 0) {
|
||||
setError(`Nepodržan format: ${invalidType[0].name}. Podržano: JPG, PNG, WEBP, HEIC, PDF, DOC, DOCX, XLS, XLSX, TXT, ZIP.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setAttachments(files);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formElement = e.currentTarget;
|
||||
setError('');
|
||||
|
||||
if (!assignedCrane) {
|
||||
setError('Nije pronađena dodijeljena dizalica. Obratite se administratoru.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.task) {
|
||||
setError('Za novi servisni zapis morate odabrati radni zadatak.');
|
||||
return;
|
||||
}
|
||||
if (!form.description.trim()) {
|
||||
setError('Opis posla je obavezan.');
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = form.cost === '' ? '0.00' : parseFloat(form.cost).toFixed(2);
|
||||
if (Number.isNaN(Number(cost))) {
|
||||
setError('Trošak mora biti valjan decimalni broj.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
task: form.task,
|
||||
description: form.description.trim(),
|
||||
parts: form.parts.trim(),
|
||||
cost,
|
||||
};
|
||||
const createdRecord = await createServiceRecord(payload);
|
||||
|
||||
if (attachments.length > 0) {
|
||||
if (isOffline || String(createdRecord.id).startsWith('offline-')) {
|
||||
setError('Upload datoteka nije dostupan u offline modu. Spremite zapis pa uploadajte datoteke kada ste online.');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
uploadAbortRef.current?.abort();
|
||||
uploadAbortRef.current = new AbortController();
|
||||
const signal = uploadAbortRef.current.signal;
|
||||
setUploadProgress({ current: 0, total: attachments.length });
|
||||
|
||||
for (const [index, file] of attachments.entries()) {
|
||||
setUploadProgress({ current: index + 1, total: attachments.length });
|
||||
const isImage = ACCEPTED_IMAGE_TYPES.includes(file.type) || /\.(jpe?g|png|webp|heic)$/i.test(file.name || '');
|
||||
if (isImage) {
|
||||
await uploadServicePhoto(createdRecord.id, file, attachmentDescription, signal);
|
||||
} else {
|
||||
await uploadServiceAttachment(createdRecord.id, file, attachmentDescription, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Servisni zapis je uspješno kreiran.', 'success');
|
||||
formElement?.reset?.();
|
||||
setAttachments([]);
|
||||
setAttachmentDescription('');
|
||||
setUploadProgress({ current: 0, total: 0 });
|
||||
onSuccess?.();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
setError(err?.message || 'Greška pri slanju servisnog zapisa.');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
uploadAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const disabled = submitting || isOffline;
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
overlayClassName="z-50 overflow-y-auto px-4 pb-4 pt-20"
|
||||
contentClassName="flex items-start justify-center min-h-full"
|
||||
panelClassName={`w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)] transition-opacity ${isOffline ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Novi servisni zapis</h3>
|
||||
{isOffline && (
|
||||
<p className="text-xs text-amber-600 font-medium mt-0.5">
|
||||
⚠ Offline mod — zapis će se sinkronizirati kad se veza vrati
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={submitting}
|
||||
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
|
||||
{/* Dizalica — read-only */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Dizalica</span>
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main">
|
||||
{assignedCrane
|
||||
? `${assignedCrane.registration_number} — ${assignedCrane.make ?? ''} ${assignedCrane.model ?? ''}`
|
||||
: 'Nema dodijeljene dizalice'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Radni zadatak *</span>
|
||||
<select
|
||||
value={form.task}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, task: event.currentTarget.value }))}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
<option value="">— Odaberi radni zadatak —</option>
|
||||
{availableTasks.map((task) => (
|
||||
<option key={task.id} value={String(task.id)}>
|
||||
{task.title} • {task.status || 'aktivan'}
|
||||
{task.work_order_label ? ` • ${task.work_order_label}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{availableTasks.length === 0 && (
|
||||
<span className="text-[11px] text-amber-600">
|
||||
Nema dostupnih radnih zadataka za odabranu dizalicu. Kreirajte novi radni zadatak.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Opis posla *</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onInput={field('description')}
|
||||
disabled={disabled}
|
||||
placeholder="Opis izvršenih radova, zamijenjenih dijelova, uočenih kvarova..."
|
||||
className="min-h-20 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50 resize-none"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Korišteni dijelovi</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={form.parts}
|
||||
onInput={field('parts')}
|
||||
disabled={disabled}
|
||||
placeholder="Npr. Filter ulja, zupčasti remen, 5L motorno ulje 5W-40..."
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-text-main">Trošak (EUR)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={form.cost}
|
||||
onInput={field('cost')}
|
||||
disabled={disabled}
|
||||
placeholder="0.00"
|
||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-border-hairline bg-canvas-base px-3 py-3">
|
||||
<p className="text-sm font-medium text-text-main">Slike / datoteke uz servis</p>
|
||||
<input
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTENSIONS.join(',')}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
className="block w-full text-xs text-text-muted file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-indigo-700 hover:file:bg-indigo-100 disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
Možete odabrati više datoteka (JPG/PNG/WEBP/HEIC/PDF/DOC/DOCX/XLS/XLSX/TXT/ZIP), max {MAX_FILE_SIZE_MB} MB po datoteci.
|
||||
</p>
|
||||
{attachments.length > 0 && (
|
||||
<div className="max-h-24 overflow-y-auto rounded border border-border-hairline bg-canvas-elevated p-2">
|
||||
{attachments.map((file, index) => (
|
||||
<p key={index} className="truncate text-[11px] text-text-muted">
|
||||
📎 {file.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={attachmentDescription}
|
||||
onInput={(event) => setAttachmentDescription(event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Opis koji se primjenjuje na sve odabrane datoteke"
|
||||
className="w-full rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main placeholder-text-muted/40 disabled:opacity-50"
|
||||
/>
|
||||
{submitting && uploadProgress.total > 1 && uploadProgress.current > 0 && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
Upload datoteka: {uploadProgress.current}/{uploadProgress.total}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep disabled:opacity-50"
|
||||
>
|
||||
Odustani
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !assignedCrane}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'Spremam...' : isOffline ? 'Spremi offline' : 'Spremi zapis'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/invoicing/InvoiceForm.jsx
Normal file
258
frontend/src/components/invoicing/InvoiceForm.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
// src/components/invoicing/InvoiceForm.jsx
|
||||
// Forma za kreiranje fakture s dinamičkim stavkama (POST /api/invoicing/invoices/)
|
||||
// Backend prima JSON body s nested 'items' poljem: [{description, quantity, unit_price}]
|
||||
// NIJE offline-queued jer fakture zahtijevaju strogu konzistentnost s backendom.
|
||||
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $isOffline } from '../../stores/networkStore.js';
|
||||
import { $clients, $invoicesLoading, fetchClients, createInvoice } from '../../stores/invoiceStore.js';
|
||||
import { showToast } from '../../stores/toastStore.js';
|
||||
|
||||
const EMPTY_ITEM = { description: '', quantity: '1', unit_price: '' };
|
||||
|
||||
const today = () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30); // Default rok: 30 dana od danas
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export default function InvoiceForm({ onSuccess }) {
|
||||
const isOffline = useStore($isOffline);
|
||||
const clients = useStore($clients);
|
||||
const loading = useStore($invoicesLoading);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [dueDate, setDueDate] = useState(today());
|
||||
const [items, setItems] = useState([{ ...EMPTY_ITEM }]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
if ($clients.get().length === 0) {
|
||||
fetchClients();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="h-96 rounded-2xl border border-border-hairline bg-canvas-elevated animate-pulse" />
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Item helpers ----
|
||||
|
||||
const setItemField = (index, field, value) => {
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))
|
||||
);
|
||||
};
|
||||
|
||||
const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
|
||||
|
||||
const removeItem = (index) => {
|
||||
if (items.length === 1) return; // Minimalno jedna stavka
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Ukupni iznos — izračun u frontendu za prikaz
|
||||
const total = items.reduce((sum, item) => {
|
||||
const qty = parseFloat(item.quantity) || 0;
|
||||
const price = parseFloat(item.unit_price) || 0;
|
||||
return sum + qty * price;
|
||||
}, 0);
|
||||
|
||||
// ---- Submit ----
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isOffline) {
|
||||
showToast('Faktura se ne može kreirati u offline modu.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
showToast('Odaberite klijenta.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = items.filter(
|
||||
(item) => item.description.trim() && parseFloat(item.quantity) > 0 && parseFloat(item.unit_price) > 0
|
||||
);
|
||||
if (validItems.length === 0) {
|
||||
showToast('Dodajte barem jednu stavku s opisom, količinom i cijenom.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
client: Number(clientId),
|
||||
due_date: dueDate,
|
||||
items: validItems.map((item) => ({
|
||||
description: item.description.trim(),
|
||||
quantity: parseFloat(item.quantity),
|
||||
unit_price: parseFloat(item.unit_price),
|
||||
})),
|
||||
};
|
||||
const invoice = await createInvoice(payload);
|
||||
setClientId('');
|
||||
setDueDate(today());
|
||||
setItems([{ ...EMPTY_ITEM }]);
|
||||
onSuccess?.(invoice);
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
showToast(err?.message || 'Greška pri kreiranju fakture.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disabled = submitting || isOffline;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-canvas-elevated border border-border-hairline p-6 rounded-2xl shadow-[0_8px_30px_rgba(0,0,0,0.15)] transition-all ${
|
||||
isOffline ? 'opacity-40 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-xs font-mono tracking-widest text-text-main uppercase border-b border-border-hairline pb-2 mb-5">
|
||||
// NOVA_FAKTURA
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Klijent + rok */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
|
||||
Klijent <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.currentTarget.value)}
|
||||
disabled={disabled || loading}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none transition-all disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{loading ? 'Učitavam klijente...' : '— Odaberi klijenta —'}
|
||||
</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.tax_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
|
||||
Rok plaćanja <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onInput={(e) => setDueDate(e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none transition-all disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stavke fakture */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-[11px] font-semibold text-text-muted">
|
||||
Stavke fakture <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
disabled={disabled}
|
||||
className="text-[11px] font-mono text-indigo-600 hover:text-indigo-800 disabled:opacity-50"
|
||||
>
|
||||
+ Dodaj stavku
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zaglavlje tablice */}
|
||||
<div className="hidden sm:grid grid-cols-[1fr_80px_100px_32px] gap-2 mb-1 px-1">
|
||||
{['Opis stavke', 'Kol.', 'Cijena (EUR)', ''].map((h) => (
|
||||
<span key={h} className="text-[10px] font-semibold text-text-muted uppercase tracking-wide">
|
||||
{h}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_80px_100px_32px] gap-2 items-start">
|
||||
<input
|
||||
type="text"
|
||||
value={item.description}
|
||||
onInput={(e) => setItemField(index, 'description', e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Naziv usluge / proizvoda"
|
||||
className="px-2.5 py-1.5 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main placeholder-text-muted/40 focus:border-indigo-500 focus:outline-none disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={item.quantity}
|
||||
onInput={(e) => setItemField(index, 'quantity', e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
className="px-2.5 py-1.5 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main focus:border-indigo-500 focus:outline-none disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={item.unit_price}
|
||||
onInput={(e) => setItemField(index, 'unit_price', e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
placeholder="0.00"
|
||||
className="px-2.5 py-1.5 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main placeholder-text-muted/40 focus:border-indigo-500 focus:outline-none disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(index)}
|
||||
disabled={disabled || items.length === 1}
|
||||
className="h-[34px] flex items-center justify-center rounded-lg text-text-muted hover:text-red-600 hover:bg-red-50 disabled:opacity-30 transition-colors"
|
||||
aria-label="Ukloni stavku"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ukupni iznos */}
|
||||
<div className="flex justify-end">
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-deep px-4 py-2 text-sm">
|
||||
<span className="text-text-muted">Ukupno (bez PDV-a): </span>
|
||||
<span className="font-bold text-text-main">
|
||||
{total.toLocaleString('hr-HR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} EUR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white text-xs py-2.5 rounded-lg font-semibold transition-all duration-200"
|
||||
>
|
||||
{submitting ? 'Kreiranje fakture...' : 'Kreiraj fakturu'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import { downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, downloadWorkOrderServiceRecordsPdf } from '../../stores/fleetDashboardStore';
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('hr-HR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function detectNotificationKind(notification) {
|
||||
const title = String(notification?.title || '').toLowerCase();
|
||||
if (title.includes('servisni kontekst')) return 'service_context';
|
||||
if (title.includes('pdf')) return 'work_order_pdf';
|
||||
if (title.includes('putni nalog kreiran')) return 'work_order_created';
|
||||
if (title.includes('novi putni nalog')) return 'work_order_assigned';
|
||||
if (title.includes('putni nalog zatvoren')) return 'work_order_closed';
|
||||
if (title.includes('putni nalog obrisan')) return 'work_order_deleted';
|
||||
if (title.includes('servis zabilježen')) return 'service_record_created';
|
||||
if (title.includes('servis na vašem vozilu')) return 'service_record_vehicle_update';
|
||||
if (title.includes('uskoro servis')) return 'service_due_warning';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
function getEntityAction(notification, meta) {
|
||||
const metadata = notification?.metadata || {};
|
||||
if (metadata.entity_type === 'service_context' && metadata.vehicle_id) {
|
||||
return {
|
||||
label: 'Otvori servisni kontekst',
|
||||
run() {
|
||||
window.dispatchEvent(new CustomEvent('notification:open-entity', {
|
||||
detail: {
|
||||
entityType: 'service_context',
|
||||
vehicleId: metadata.vehicle_id,
|
||||
section: metadata.section || meta.section,
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
metadata.entity_type === 'work_order_pdf' &&
|
||||
metadata.stage === 'completed' &&
|
||||
metadata.work_order_id
|
||||
) {
|
||||
return {
|
||||
label: metadata.pdf_type === 'invoices'
|
||||
? 'Preuzmi PDF računa'
|
||||
: metadata.pdf_type === 'service_records'
|
||||
? 'Preuzmi PDF servisnih zapisa'
|
||||
: 'Preuzmi PDF putnog naloga',
|
||||
run() {
|
||||
if (metadata.pdf_type === 'invoices') {
|
||||
return downloadWorkOrderInvoicesPdf(metadata.work_order_id);
|
||||
}
|
||||
if (metadata.pdf_type === 'service_records') {
|
||||
return downloadWorkOrderServiceRecordsPdf(metadata.work_order_id);
|
||||
}
|
||||
return downloadWorkOrderPdf(metadata.work_order_id);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (metadata.entity_type === 'work_order' && metadata.work_order_id) {
|
||||
return {
|
||||
label: 'Otvori detalje naloga',
|
||||
run() {
|
||||
window.dispatchEvent(new CustomEvent('notification:open-entity', {
|
||||
detail: {
|
||||
entityType: 'work_order',
|
||||
entityId: metadata.work_order_id,
|
||||
vehicleId: metadata.vehicle_id || null,
|
||||
section: metadata.section || meta.section,
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
if (metadata.entity_type === 'service_record' && metadata.service_record_id) {
|
||||
return {
|
||||
label: 'Otvori detalje servisa',
|
||||
run() {
|
||||
window.dispatchEvent(new CustomEvent('notification:open-entity', {
|
||||
detail: {
|
||||
entityType: 'service_record',
|
||||
entityId: metadata.service_record_id,
|
||||
vehicleId: metadata.vehicle_id || null,
|
||||
section: metadata.section || meta.section,
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `Otvori ${meta.category}`,
|
||||
run() {
|
||||
window.dispatchEvent(new CustomEvent('navbar:navigate', { detail: meta.section }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getNotificationTypeMeta(kind) {
|
||||
if (kind.startsWith('work_order_pdf')) {
|
||||
return {
|
||||
category: 'PDF dokumenti',
|
||||
section: 'work-orders',
|
||||
recommendation: 'Možete odmah preuzeti generirani PDF dokument.',
|
||||
};
|
||||
}
|
||||
if (kind.startsWith('work_order')) {
|
||||
return {
|
||||
category: 'Putni nalozi',
|
||||
section: 'work-orders',
|
||||
recommendation: 'Provjerite status naloga i povezano vozilo.',
|
||||
};
|
||||
}
|
||||
if (kind === 'service_context') {
|
||||
return {
|
||||
category: 'Servisni kontekst',
|
||||
section: 'service-records',
|
||||
recommendation: 'Kontekst je postavljen i spreman za rad na servisnim zapisima.',
|
||||
};
|
||||
}
|
||||
if (kind.startsWith('service_')) {
|
||||
return {
|
||||
category: 'Servisni zapisi',
|
||||
section: 'service-records',
|
||||
recommendation: 'Provjerite detalje servisa i priloge.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
category: 'Sustav',
|
||||
section: 'dashboard',
|
||||
recommendation: 'Pregledajte dashboard za dodatni kontekst.',
|
||||
};
|
||||
}
|
||||
|
||||
function levelClass(level) {
|
||||
if (level === 'critical') return 'bg-red-100 text-red-700';
|
||||
if (level === 'warning') return 'bg-amber-100 text-amber-700';
|
||||
return 'bg-indigo-100 text-indigo-700';
|
||||
}
|
||||
|
||||
export default function NotificationDetailModal({ open, notification, onClose }) {
|
||||
const kind = useMemo(() => detectNotificationKind(notification), [notification]);
|
||||
const meta = useMemo(() => getNotificationTypeMeta(kind), [kind]);
|
||||
const action = useMemo(() => getEntityAction(notification, meta), [notification, meta]);
|
||||
const showPdfLink = Boolean(
|
||||
notification?.metadata?.entity_type === 'work_order_pdf' &&
|
||||
notification?.metadata?.stage === 'completed' &&
|
||||
notification?.metadata?.work_order_id
|
||||
);
|
||||
|
||||
if (!open || !notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
overlayClassName="z-50 p-4"
|
||||
contentClassName="flex items-center justify-center min-h-full"
|
||||
panelClassName="max-h-[calc(100vh-2rem)] w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-y-auto"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-main">Detalji notifikacije</h3>
|
||||
<p className="text-xs text-text-muted">{formatTimestamp(notification.created_at)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${levelClass(notification.level)}`}>
|
||||
{notification.level || 'info'}
|
||||
</span>
|
||||
<span className="rounded-full bg-canvas-base px-2.5 py-1 text-xs font-semibold text-text-muted">
|
||||
{meta.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<p className="text-sm font-semibold text-text-main">{notification.title || 'Obavijest'}</p>
|
||||
<p className="mt-2 text-sm text-text-muted">{notification.message || '-'}</p>
|
||||
{showPdfLink && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => action.run()}
|
||||
className="mt-2 text-sm font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-700"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Preporuka</p>
|
||||
<p className="mt-1 text-sm text-text-main">{meta.recommendation}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
action.run();
|
||||
onClose?.();
|
||||
}}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Zatvori
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
143
frontend/src/components/tasks/TaskForm.jsx
Normal file
143
frontend/src/components/tasks/TaskForm.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
// src/components/tasks/TaskForm.jsx
|
||||
// Forma za kreiranje novog zadatka (POST /api/tasks/tasks/)
|
||||
// Prati TodoForm.jsx arhitektonski obrazac.
|
||||
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $isOffline } from '../../stores/networkStore.js';
|
||||
import { $tasks, createTask, fetchTasks, getStatusLabel } from '../../stores/taskStore.js';
|
||||
import { showToast } from '../../stores/toastStore.js';
|
||||
|
||||
const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
|
||||
|
||||
const INITIAL = {
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'aktivan',
|
||||
};
|
||||
|
||||
export default function TaskForm() {
|
||||
const isOffline = useStore($isOffline);
|
||||
const [form, setForm] = useState(INITIAL);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
// Učitaj zadatke na mount ako su prazni
|
||||
if ($tasks.get().length === 0 && !isOffline) {
|
||||
fetchTasks();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
// SSR placeholder — iste dimenzije kao forma da se izbjegne layout shift
|
||||
return (
|
||||
<div className="h-64 rounded-2xl border border-border-hairline bg-canvas-elevated animate-pulse" />
|
||||
);
|
||||
}
|
||||
|
||||
const field = (name) => (e) => setForm((prev) => ({ ...prev, [name]: e.currentTarget.value }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isOffline) {
|
||||
showToast('Nije moguće kreirati zadatak u offline modu.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.title.trim()) {
|
||||
showToast('Naslov zadatka je obavezan.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTask({
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
status: form.status,
|
||||
});
|
||||
e.target.reset();
|
||||
setForm(INITIAL);
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
showToast(err?.message || 'Greška pri kreiranju zadatka.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disabled = submitting || isOffline;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-canvas-elevated border border-border-hairline p-6 rounded-2xl shadow-[0_8px_30px_rgba(0,0,0,0.15)] h-fit transition-all ${
|
||||
isOffline ? 'opacity-40 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-xs font-mono tracking-widest text-text-main uppercase border-b border-border-hairline pb-2 mb-4">
|
||||
// NOVI_ZADATAK
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
|
||||
Naslov zadatka <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onInput={field('title')}
|
||||
disabled={disabled}
|
||||
placeholder="Npr. Pregled hydrauličkog sustava dizalice..."
|
||||
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main placeholder-text-muted/40 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none transition-all disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
|
||||
Opis / Tehnička napomena
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onInput={field('description')}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
placeholder="Detalji zadatka, lokacija, prioritet, šifre grešaka..."
|
||||
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main placeholder-text-muted/40 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 resize-none focus:outline-none transition-all disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={field('status')}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none transition-all disabled:opacity-50"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{getStatusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white text-xs py-2.5 rounded-lg font-semibold transition-all duration-200"
|
||||
>
|
||||
{submitting ? 'Kreiranje...' : 'Kreiraj zadatak'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/toast/ToastItem.tsx
Normal file
35
frontend/src/components/toast/ToastItem.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ToastMessage } from '../../hooks/useToast';
|
||||
|
||||
const STYLES: Record<ToastMessage['type'], string> = {
|
||||
success: 'border-emerald-300 bg-emerald-500/15 text-emerald-100',
|
||||
error: 'border-red-300 bg-red-500/15 text-red-100',
|
||||
warning: 'border-amber-300 bg-amber-500/15 text-amber-100',
|
||||
info: 'border-sky-300 bg-sky-500/15 text-sky-100',
|
||||
};
|
||||
|
||||
interface ToastItemProps {
|
||||
toast: ToastMessage;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ToastItem({ toast, onClose }: ToastItemProps) {
|
||||
return (
|
||||
<article
|
||||
className={`pointer-events-auto rounded-lg border px-4 py-3 shadow-lg backdrop-blur ${STYLES[toast.type]}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm">{toast.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-80 hover:opacity-100"
|
||||
onClick={() => onClose(toast.id)}
|
||||
aria-label="Close toast"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/toast/ToastProvider.tsx
Normal file
28
frontend/src/components/toast/ToastProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import ToastItem from './ToastItem';
|
||||
import { removeToast, toastMessages } from '../../hooks/useToast';
|
||||
|
||||
export default function ToastProvider() {
|
||||
const toasts = Array.isArray(toastMessages.value) ? toastMessages.value : [];
|
||||
|
||||
useEffect(() => {
|
||||
const timers = toasts.map((toast) =>
|
||||
window.setTimeout(() => removeToast(toast.id), toast.durationMs)
|
||||
);
|
||||
return () => {
|
||||
timers.forEach((timer) => window.clearTimeout(timer));
|
||||
};
|
||||
}, [toasts]);
|
||||
|
||||
if (!toasts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="pointer-events-none fixed right-4 top-4 z-[80] flex w-full max-w-sm flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={removeToast} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/ui/AnimatedDataTable.jsx
Normal file
82
frontend/src/components/ui/AnimatedDataTable.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function AnimatedDataTable({
|
||||
columns = [],
|
||||
rows = [],
|
||||
rowKey = (row) => row?.id,
|
||||
renderRow,
|
||||
loading = false,
|
||||
loadingMessage = 'Učitavanje...',
|
||||
emptyMessage = 'Nema podataka.',
|
||||
tableClassName = 'min-w-full text-sm',
|
||||
headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
|
||||
bodyClassName = 'divide-y divide-border-hairline',
|
||||
rowClassName = 'hover:bg-canvas-deep',
|
||||
wrapperClassName = 'overflow-x-auto',
|
||||
trail = 35,
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const transitions = useTransition(rows, {
|
||||
keys: (row) => rowKey(row),
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
||||
trail,
|
||||
config: { tension: 230, friction: 26 },
|
||||
});
|
||||
|
||||
const colSpan = Math.max(1, columns.length);
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<table className={tableClassName}>
|
||||
<thead className={headClassName}>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className={column.className}>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={bodyClassName}>
|
||||
{!mounted && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
{loadingMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mounted && loading && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
{loadingMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mounted && !loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mounted && !loading && transitions((style, row) => (
|
||||
<animated.tr
|
||||
key={rowKey(row)}
|
||||
style={style}
|
||||
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
|
||||
>
|
||||
{renderRow(row)}
|
||||
</animated.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/ui/AnimatedPage.jsx
Normal file
15
frontend/src/components/ui/AnimatedPage.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
export default function AnimatedPage({ children, className = '' }) {
|
||||
const pageStyle = useSpring({
|
||||
from: { opacity: 0, transform: 'translate3d(0,10px,0)' },
|
||||
to: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
config: { tension: 210, friction: 24 },
|
||||
});
|
||||
|
||||
return (
|
||||
<animated.div style={pageStyle} className={className}>
|
||||
{children}
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ui/ModalShell.jsx
Normal file
51
frontend/src/components/ui/ModalShell.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { createPortal } from 'preact/compat';
|
||||
|
||||
function joinClasses(...classes) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function ModalShell({
|
||||
children,
|
||||
onClose,
|
||||
overlayClassName = '',
|
||||
contentClassName = 'flex min-h-full items-center justify-center',
|
||||
panelClassName = '',
|
||||
closeOnBackdrop = true,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
const count = parseInt(body.dataset.modalCount || '0', 10);
|
||||
body.dataset.modalCount = count + 1;
|
||||
body.classList.add('modal-open');
|
||||
return () => {
|
||||
const next = parseInt(body.dataset.modalCount || '0', 10) - 1;
|
||||
body.dataset.modalCount = next;
|
||||
if (next <= 0) {
|
||||
body.classList.remove('modal-open');
|
||||
delete body.dataset.modalCount;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOverlayClick = closeOnBackdrop
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
onClose?.(event);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={joinClasses('fixed inset-0', overlayClassName)}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className={contentClassName}>
|
||||
<div className={panelClassName} onClick={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/NetworkGuard.jsx
Normal file
20
frontend/src/components/ui/NetworkGuard.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $isOffline } from '../../stores/networkStore';
|
||||
|
||||
export default function NetworkGuard() {
|
||||
const isOffline = useStore($isOffline);
|
||||
if (!isOffline) return null;
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed top-0 left-0 right-0 z-20 p-2 text-[11px] font-mono tracking-wider text-amber-300 bg-amber-500/10 border-b border-amber-500/20 backdrop-blur-md text-center"
|
||||
role="alert"
|
||||
>
|
||||
<span className="font-bold">STATUS_OFFLINE:</span>{' '}
|
||||
{isDev
|
||||
? 'Veza s Django API-jem (8001) je prekinuta. Podaci se čitaju iz lokalnog cachea.'
|
||||
: 'Veza je prekinuta. Podaci se čitaju iz lokalnog cachea.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/ui/ThemeToggle.jsx
Normal file
41
frontend/src/components/ui/ThemeToggle.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const imaDarkKlasu = document.documentElement.classList.contains('dark');
|
||||
setIsDark(imaDarkKlasu);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
setIsDark(false);
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
setIsDark(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
type="button"
|
||||
className="p-2.5 rounded-s-xl bg-canvas-elevated border-y border-s border-border-hairline text-text-muted hover:text-text-main transition-all duration-200 focus:outline-none cursor-pointer flex items-center justify-center"
|
||||
aria-label="Prebaci temu"
|
||||
>
|
||||
{isDark ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M12 7a5 5 0 100 10 5 5 0 000-10z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 118.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/ui/Toast.jsx
Normal file
5
frontend/src/components/ui/Toast.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ToastContainer from '../ToastContainer';
|
||||
|
||||
export default function Toast() {
|
||||
return <ToastContainer />;
|
||||
}
|
||||
151
frontend/src/components/ui/UserDisplay.jsx
Normal file
151
frontend/src/components/ui/UserDisplay.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
// src/components/ui/UserDisplay.jsx
|
||||
//
|
||||
// Prikazuje podatke o korisniku direktno iz $user nanostores atoma.
|
||||
//
|
||||
// ARHITEKTONSKA ODLUKA — zašto store, ne prop:
|
||||
// • $user je reaktivan atom — svaka promjena (login, refresh profila)
|
||||
// automatski re-renderira sve UserDisplay instance bez prop-drillinga
|
||||
// • Komponenta se poziva na više mjesta (Topbar, Sidebar, Profil) —
|
||||
// proslijeđivanje user objekta kao prop-a kroz sve te slojeve je anti-pattern
|
||||
//
|
||||
// USAGE:
|
||||
// <UserDisplay field="ime_prezime" /> — "Ivo Horvat"
|
||||
// <UserDisplay field="ime" /> — "Ivo"
|
||||
// <UserDisplay field="prezime" /> — "Horvat"
|
||||
// <UserDisplay field="email" /> — "ivo@example.com"
|
||||
// <UserDisplay field="uloga" /> — "Serviser" | "Administrator" | "Korisnik"
|
||||
// <UserDisplay field="ime_prezime" variant="badge" /> — avatar krug + ime
|
||||
// <UserDisplay field="ime_prezime" variant="card" /> — puna kartica s ulogom
|
||||
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { $user, $authReady } from '../../stores/authStore.js';
|
||||
|
||||
// ─── Mapa razrješivača polja ────────────────────────────────────────────────
|
||||
// Dodavanje novog polja = jedna nova linija ovdje.
|
||||
const FIELD_RESOLVER = {
|
||||
ime_prezime: (u) =>
|
||||
[`${u.first_name ?? ''}`.trim(), `${u.last_name ?? ''}`.trim()]
|
||||
.filter(Boolean)
|
||||
.join(' ') || u.email || '—',
|
||||
ime: (u) => u.first_name?.trim() || u.email || '—',
|
||||
prezime: (u) => u.last_name?.trim() || '—',
|
||||
email: (u) => u.email || '—',
|
||||
uloga: (u) => {
|
||||
if (u.is_staff) return 'Administrator';
|
||||
if (u.is_serviser) return 'Serviser';
|
||||
return 'Korisnik';
|
||||
},
|
||||
};
|
||||
|
||||
// Dohvati inicijale za avatar krug (max 2 slova)
|
||||
function getInitials(user) {
|
||||
if (!user) return '?';
|
||||
const first = user.first_name?.trim()?.[0] ?? '';
|
||||
const last = user.last_name?.trim()?.[0] ?? '';
|
||||
if (first || last) return `${first}${last}`.toUpperCase();
|
||||
return (user.email?.[0] ?? '?').toUpperCase();
|
||||
}
|
||||
|
||||
// ─── Varijante prikaza ──────────────────────────────────────────────────────
|
||||
|
||||
function TextVariant({ value, className }) {
|
||||
return (
|
||||
<span className={className ?? 'text-sm font-medium text-text-main'}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeVariant({ user, value }) {
|
||||
// Kompaktni prikaz za navigacijske trake — krug s inicijalima + ime
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-[11px] font-bold text-white select-none">
|
||||
{getInitials(user)}
|
||||
</span>
|
||||
<span className="hidden text-sm font-medium text-text-main sm:inline">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardVariant({ user, value }) {
|
||||
// Prošireni prikaz — avatar + ime + uloga
|
||||
const role = FIELD_RESOLVER.uloga(user);
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-bold text-white select-none">
|
||||
{getInitials(user)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-text-main">{value}</p>
|
||||
<p className="text-[11px] text-text-muted">{role}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton za loading stanje — iste dimenzije kao sadržaj, bez layout shifta
|
||||
function Skeleton({ variant }) {
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-canvas-deep animate-pulse" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-24 rounded bg-canvas-deep animate-pulse" />
|
||||
<div className="h-2.5 w-16 rounded bg-canvas-deep animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (variant === 'badge') {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 w-7 rounded-full bg-canvas-deep animate-pulse" />
|
||||
<div className="hidden h-3 w-20 rounded bg-canvas-deep animate-pulse sm:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="h-3 w-20 rounded bg-canvas-deep animate-pulse" />;
|
||||
}
|
||||
|
||||
// ─── Glavna komponenta ──────────────────────────────────────────────────────
|
||||
|
||||
export default function UserDisplay({
|
||||
field = 'ime_prezime',
|
||||
variant = 'text',
|
||||
fallback = '—',
|
||||
className,
|
||||
}) {
|
||||
const user = useStore($user);
|
||||
const authReady = useStore($authReady);
|
||||
// SSR guard — na serveru uvijek renderira skeleton kako bi hydration bio miran
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
useEffect(() => { setHasMounted(true); }, []);
|
||||
|
||||
// Prije mount-a (SSR) i dok auth nije spreman — skeleton
|
||||
if (!hasMounted || !authReady) {
|
||||
return <Skeleton variant={variant} />;
|
||||
}
|
||||
|
||||
// Auth spreman ali user nije učitan (nije prijavljen)
|
||||
if (!user) {
|
||||
return variant === 'text'
|
||||
? <span className={className ?? 'text-sm text-text-muted'}>{fallback}</span>
|
||||
: <Skeleton variant={variant} />;
|
||||
}
|
||||
|
||||
const resolver = FIELD_RESOLVER[field];
|
||||
if (!resolver) {
|
||||
console.warn(`[UserDisplay] Nepoznato polje: "${field}". Dostupna polja: ${Object.keys(FIELD_RESOLVER).join(', ')}`);
|
||||
return <span className="text-sm text-red-500">?{field}</span>;
|
||||
}
|
||||
|
||||
const value = resolver(user);
|
||||
|
||||
if (variant === 'badge') return <BadgeVariant user={user} value={value} />;
|
||||
if (variant === 'card') return <CardVariant user={user} value={value} />;
|
||||
return <TextVariant value={value} className={className} />;
|
||||
}
|
||||
21
frontend/src/config.css
Normal file
21
frontend/src/config.css
Normal file
@@ -0,0 +1,21 @@
|
||||
:root {
|
||||
--app-canvas-base: #ffffff;
|
||||
--app-canvas-elevated: #f9fafb;
|
||||
--app-canvas-deep: #f3f4f6;
|
||||
--app-brand-primary: #5c6bc0;
|
||||
--app-brand-accent: #5e6ad2;
|
||||
--app-brand-accent-bright: #4d59c2;
|
||||
--app-text-main: #1f2937;
|
||||
--app-text-muted: #4b5563;
|
||||
--app-border-hairline: rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--app-canvas-base: #1f2329;
|
||||
--app-canvas-elevated: #2a2f36;
|
||||
--app-canvas-deep: #343b44;
|
||||
--app-brand-primary: #6f7de0;
|
||||
--app-text-main: #f4f6fb;
|
||||
--app-text-muted: #b3bcc8;
|
||||
--app-border-hairline: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
44
frontend/src/hooks/useToast.ts
Normal file
44
frontend/src/hooks/useToast.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { signal } from '@preact/signals';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export const toastMessages = signal<ToastMessage[]>([]);
|
||||
|
||||
function getToastMessages(): ToastMessage[] {
|
||||
return Array.isArray(toastMessages.value) ? toastMessages.value : [];
|
||||
}
|
||||
|
||||
function nextId(): string {
|
||||
return `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
}
|
||||
|
||||
export function removeToast(id: string): void {
|
||||
toastMessages.value = getToastMessages().filter((toast) => toast.id !== id);
|
||||
}
|
||||
|
||||
export function pushToast(
|
||||
message: string,
|
||||
type: ToastType = 'info',
|
||||
durationMs = 3000
|
||||
): string {
|
||||
const id = nextId();
|
||||
toastMessages.value = [{ id, type, message, durationMs }, ...getToastMessages()].slice(0, 5);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
success: (message: string, durationMs?: number) => pushToast(message, 'success', durationMs),
|
||||
error: (message: string, durationMs?: number) => pushToast(message, 'error', durationMs),
|
||||
info: (message: string, durationMs?: number) => pushToast(message, 'info', durationMs),
|
||||
warning: (message: string, durationMs?: number) => pushToast(message, 'warning', durationMs),
|
||||
dismiss: removeToast,
|
||||
};
|
||||
}
|
||||
105
frontend/src/layouts/Layout.astro
Normal file
105
frontend/src/layouts/Layout.astro
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import '../config.css';
|
||||
import '../styles/global.css';
|
||||
import ThemeToggle from '../components/ui/ThemeToggle.jsx';
|
||||
import ButtonDisplayCounter from '../components/atom/ButtonDisplayCounter.jsx';
|
||||
import Toast from '../components/ui/Toast.jsx';
|
||||
import ToastTrigger from '../components/ToastTrigger.jsx';
|
||||
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
|
||||
import Navbar from '../components/Navbar.jsx';
|
||||
import ToastProvider from '../components/toast/ToastProvider';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
isAuthPage?: boolean;
|
||||
minimalNav?: boolean;
|
||||
}
|
||||
|
||||
const { title, isAuthPage = false, minimalNav = false } = Astro.props as Props;
|
||||
const isProd = import.meta.env.PROD;
|
||||
const isDev = import.meta.env.DEV;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="hr" class="h-full scroll-smooth">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/pwa-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<ClientRouter />
|
||||
<title>{title}</title>
|
||||
|
||||
<script is:inline>
|
||||
function primijeniTemu() {
|
||||
const lokalnaTema = localStorage.getItem('theme');
|
||||
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
primijeniTemu();
|
||||
document.addEventListener('astro:page-load', primijeniTemu);
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="relative min-h-screen bg-canvas-base text-text-main font-sans antialiased transition-colors duration-300">
|
||||
<div class="absolute inset-0 bg-[linear-gradient(to_right,var(--color-text-main)_0.03_1px,transparent_1px),linear-gradient(to_bottom,var(--color-text-main)_0.03_1px,transparent_1px)] bg-[size:64px_64px] opacity-[0.4] dark:opacity-[0.02] pointer-events-none z-0"></div>
|
||||
|
||||
{!isAuthPage && <NetworkGuard client:only="preact" />}
|
||||
<Toast client:load />
|
||||
<ToastProvider client:load />
|
||||
{!isAuthPage && isDev && <ToastTrigger client:load />}
|
||||
{!isAuthPage && <Navbar client:load minimal={minimalNav} />}
|
||||
|
||||
<div id="main-content" class="relative z-10 flex flex-col min-h-screen max-w-6xl mx-auto px-6 md:px-8 py-6">
|
||||
<header class="flex flex-row justify-between items-end pb-6 mb-8 border-b border-border-hairline">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[10px] font-mono tracking-widest text-text-muted opacity-60 uppercase">// CORE_INFRASTRUCTURE</span>
|
||||
<h1 class="text-xl font-bold bg-gradient-to-b from-text-main to-text-main/80 bg-clip-text text-transparent tracking-tight">
|
||||
Astro ERP Platforma
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 w-full" transition:animate="fade">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="mt-20 pt-6 border-t border-border-hairline flex flex-row justify-between items-center text-[11px] font-mono text-text-muted opacity-50">
|
||||
<span>SYSTEM_STATUS: ACTIVE</span>
|
||||
<span>© {new Date().getFullYear()} CORE_LOGISTICS.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ isProd }}>
|
||||
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && window.isSecureContext) {
|
||||
window.addEventListener('load', async function () {
|
||||
if (isProd) {
|
||||
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
|
||||
console.error('Service worker registration failed:', error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((registration) => registration.unregister()));
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith('erp-shell-'))
|
||||
.map((key) => caches.delete(key))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
112
frontend/src/lib/api.ts
Normal file
112
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
|
||||
constructor(message: string, status: number, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchJsonOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
|
||||
|
||||
function buildUrl(endpoint: string): string {
|
||||
return new URL(endpoint.replace(/^\/+/, ''), API_BASE).toString();
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
return { ...headers };
|
||||
}
|
||||
|
||||
function createTimeoutSignal(signal: AbortSignal | null | undefined, timeoutMs: number): {
|
||||
signal: AbortSignal;
|
||||
clear: () => void;
|
||||
} {
|
||||
const timeoutController = new AbortController();
|
||||
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
|
||||
|
||||
const clear = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clear();
|
||||
timeoutController.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
return { signal: timeoutController.signal, clear };
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(endpoint: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||
const {
|
||||
body,
|
||||
headers,
|
||||
timeoutMs = 10000,
|
||||
signal,
|
||||
...rest
|
||||
} = options;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...normalizeHeaders(headers),
|
||||
};
|
||||
|
||||
let requestBody: BodyInit | undefined;
|
||||
if (body !== undefined && body !== null) {
|
||||
requestHeaders['Content-Type'] = 'application/json';
|
||||
requestBody = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const timeout = createTimeoutSignal(signal, timeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(buildUrl(endpoint), {
|
||||
...rest,
|
||||
headers: requestHeaders,
|
||||
body: requestBody,
|
||||
signal: timeout.signal,
|
||||
});
|
||||
} finally {
|
||||
timeout.clear();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let details: unknown = null;
|
||||
try {
|
||||
details = await response.json();
|
||||
} catch {
|
||||
details = await response.text().catch(() => null);
|
||||
}
|
||||
const message =
|
||||
typeof details === 'object' && details && 'detail' in details
|
||||
? String((details as { detail: unknown }).detail)
|
||||
: `API request failed (${response.status})`;
|
||||
throw new ApiError(message, response.status, details);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
53
frontend/src/lib/db.ts
Normal file
53
frontend/src/lib/db.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||
|
||||
const DB_NAME = 'erp-cache-db';
|
||||
const DB_VERSION = 1;
|
||||
const CACHE_STORE = 'api_cache';
|
||||
|
||||
interface CacheEntry<T = unknown> {
|
||||
key: string;
|
||||
payload: T;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ERPDBSchema extends DBSchema {
|
||||
api_cache: {
|
||||
key: string;
|
||||
value: CacheEntry;
|
||||
};
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<ERPDBSchema>> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBPDatabase<ERPDBSchema>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<ERPDBSchema>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(CACHE_STORE)) {
|
||||
db.createObjectStore(CACHE_STORE, { keyPath: 'key' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function setCachedData<T>(key: string, payload: T): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put(CACHE_STORE, {
|
||||
key,
|
||||
payload,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCachedData<T>(key: string): Promise<CacheEntry<T> | null> {
|
||||
const db = await getDB();
|
||||
const row = await db.get(CACHE_STORE, key);
|
||||
return (row as CacheEntry<T> | undefined) ?? null;
|
||||
}
|
||||
|
||||
export async function deleteCachedData(key: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.delete(CACHE_STORE, key);
|
||||
}
|
||||
22
frontend/src/lib/displayIds.js
Normal file
22
frontend/src/lib/displayIds.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export function shortUuid(id, size = 8) {
|
||||
if (id == null) return '';
|
||||
const raw = String(id).trim();
|
||||
if (!raw) return '';
|
||||
const compact = raw.split('-')[0] || raw;
|
||||
return compact.slice(0, size).toUpperCase();
|
||||
}
|
||||
|
||||
export function formatEntityCode(prefix, id, size = 8) {
|
||||
const short = shortUuid(id, size);
|
||||
if (!short) return `${prefix}-`;
|
||||
return `${prefix}-${short}`;
|
||||
}
|
||||
|
||||
export function formatPurposeLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (!normalized) return '';
|
||||
if (normalized === 'defektaza') return 'Defektaža';
|
||||
if (normalized === 'kontrola') return 'Kontrola';
|
||||
if (normalized === 'redovni_pregled') return 'Redovni pregled';
|
||||
return value || '';
|
||||
}
|
||||
9
frontend/src/pages/clients.astro
Normal file
9
frontend/src/pages/clients.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Klijenti">
|
||||
<FleetDashboardShell client:load pageMode="clients" initialSection="clients" />
|
||||
</Layout>
|
||||
37
frontend/src/pages/data/[...slug].astro
Normal file
37
frontend/src/pages/data/[...slug].astro
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import DataView from '../../components/data/DataView';
|
||||
import { fetchJson } from '../../lib/api';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const slugParts = Astro.params.slug?.split('/').filter(Boolean) ?? [];
|
||||
const endpoint = slugParts.length ? `${slugParts.join('/')}/` : 'fleet/work-orders/';
|
||||
const cacheKey = `data:${endpoint}`;
|
||||
|
||||
let initialData: unknown[] | null = null;
|
||||
let initialError: string | null = null;
|
||||
|
||||
try {
|
||||
// SSR fetch for first paint; client island revalidates and syncs IndexedDB after hydration.
|
||||
const payload = await fetchJson<unknown[]>(endpoint, { method: 'GET', timeoutMs: 8000 });
|
||||
initialData = Array.isArray(payload) ? payload : [];
|
||||
} catch (error) {
|
||||
initialError = error instanceof Error ? error.message : 'SSR data fetch failed.';
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title={`Data | ${endpoint}`}>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-text-muted">
|
||||
Endpoint: <code class="rounded bg-canvas-deep px-2 py-1 text-text-main">{endpoint}</code>
|
||||
</p>
|
||||
<DataView
|
||||
client:load
|
||||
endpoint={endpoint}
|
||||
cacheKey={cacheKey}
|
||||
initialData={initialData}
|
||||
initialError={initialError}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
9
frontend/src/pages/index.astro
Normal file
9
frontend/src/pages/index.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
|
||||
---
|
||||
|
||||
<Layout title="ERP Dashboard">
|
||||
<FleetDashboardShell client:load />
|
||||
</Layout>
|
||||
10
frontend/src/pages/login.astro
Normal file
10
frontend/src/pages/login.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import LoginForm from '../components/auth/LoginForm.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Prijava" isAuthPage={true}>
|
||||
<div class="flex min-h-[60vh] items-center justify-center py-10">
|
||||
<LoginForm client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
9
frontend/src/pages/putni-nalozi.astro
Normal file
9
frontend/src/pages/putni-nalozi.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Putni nalozi">
|
||||
<FleetDashboardShell client:load pageMode="work-orders" initialSection="work-orders" />
|
||||
</Layout>
|
||||
9
frontend/src/pages/putni-nalozi/racuni.astro
Normal file
9
frontend/src/pages/putni-nalozi/racuni.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import WorkOrderInvoicesPdfPage from '../../components/dashboard/WorkOrderInvoicesPdfPage.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Izrada putnog naloga" minimalNav={false}>
|
||||
<WorkOrderInvoicesPdfPage client:load />
|
||||
</Layout>
|
||||
9
frontend/src/pages/service-recordsssss.astro
Normal file
9
frontend/src/pages/service-recordsssss.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Servisni zapisi">
|
||||
<FleetDashboardShell client:load pageMode="service-records" initialSection="service-records" />
|
||||
</Layout>
|
||||
9
frontend/src/pages/servisni-zapisi.astro
Normal file
9
frontend/src/pages/servisni-zapisi.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Servisni zapisi">
|
||||
<FleetDashboardShell client:load pageMode="service-records" initialSection="service-records" />
|
||||
</Layout>
|
||||
9
frontend/src/pages/vehicles.astro
Normal file
9
frontend/src/pages/vehicles.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Vozila i dizalice">
|
||||
<FleetDashboardShell client:load pageMode="vehicles" initialSection="cranes" />
|
||||
</Layout>
|
||||
9
frontend/src/pages/work-orders.astro
Normal file
9
frontend/src/pages/work-orders.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Putni nalozi">
|
||||
<FleetDashboardShell client:load pageMode="work-orders" initialSection="work-orders" />
|
||||
</Layout>
|
||||
133
frontend/src/services/apiClient.js
Normal file
133
frontend/src/services/apiClient.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// src/services/apiClient.js
|
||||
|
||||
/**
|
||||
* 🌐 CENTRALIZIRANI API KLIJENT / MREŽNI POSREDNIK (API INTERCEPTOR WRAPPER)
|
||||
* * * [Arhitektonska uloga]:
|
||||
* Služi kao jedinstveni apstraktni sloj (Wrapper) oko nativne pregledničke `fetch` funkcije.
|
||||
* Njegova je zadaća izolirati kompletnu logiku mrežne komunikacije, zaglavlja i serijalizacije
|
||||
* iz Preact UI komponenti i NanoStores skladišta, osiguravajući stopostotni DRY princip.
|
||||
* * * [Ključne tehničke funkcionalnosti]:
|
||||
* 1. Dinamička konfiguracija okruženja: Automatski čita `PUBLIC_API_URL` preko Vite kompajlera.
|
||||
* 2. Pametno upravljanje zaglavljima: Automatski presreće mrežne zahtjeve i injektira
|
||||
* `Authorization: Bearer <token>` zaglavlje iz klijentovog memorijskog `authStore`-a.
|
||||
* 3. Detekcija polimorfnih paketa: Prepoznaje razliku između običnog `application/json` unosa
|
||||
* i `FormData` objekata (slike s terena), sprječavajući korupciju mrežnog paketa.
|
||||
* 4. Robusni parser validacijskih grešaka: Izvlači i tekstualno formatira kompleksne DRF
|
||||
* strukture grešaka (npr. pogreške po poljima ili nizove) kako bi Toast sustav stabilno radio.
|
||||
*/
|
||||
|
||||
import { $accessToken, setToken } from '../stores/authStore';
|
||||
|
||||
const KLIJENT_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
|
||||
const POSLUZITELJ_URL = 'http://backend:8000/api/'; // 💡 Promijeni 'backend' u točan naziv tvog Django servisa iz docker-compose.yml!
|
||||
|
||||
const BASE_URL = typeof window === 'undefined' ? POSLUZITELJ_URL : KLIJENT_URL;
|
||||
let refreshPromise = null;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[API_CLIENT]: Pokrenut mod. Lokacija: ${typeof window === 'undefined' ? 'SERVER (Docker)' : 'CLIENT (Browser)'} -> Endpoint: ${BASE_URL}`);
|
||||
}
|
||||
|
||||
async function request(endpoint, options = {}) {
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
||||
const url = new URL(endpoint.replace(/^\/+/, ''), BASE_URL).toString();
|
||||
const headers = { ...options.headers };
|
||||
|
||||
const isFormData = options.body && (
|
||||
options.body instanceof FormData ||
|
||||
typeof options.body.append === 'function'
|
||||
);
|
||||
|
||||
if (options.body && !isFormData) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// 🔑 PAMETNA AUTORIZACIJA: Ako je proslijeđen eksplicitni serverToken (iz Astro.cookies), koristi njega.
|
||||
// U suprotnom, povuci iz NanoStores (klijent mod).
|
||||
let token = options.serverToken || $accessToken.get();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let body = options.body;
|
||||
if (body && !isFormData && typeof body !== 'string') {
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const config = { ...options, headers, body };
|
||||
|
||||
try {
|
||||
let response = await fetch(url, config);
|
||||
|
||||
// Presretač za tihi refresh (Radi samo na klijentu, jer server ne radi automatski tihi refresh)
|
||||
if (response.status === 401 && token && !cleanEndpoint.includes('token/refresh/') && typeof window !== 'undefined') {
|
||||
console.warn("Access token istekao. Pokrećem tihi refresh...");
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const refreshResponse = await fetch(`${BASE_URL}token/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh: refreshToken }),
|
||||
});
|
||||
|
||||
if (!refreshResponse.ok) {
|
||||
throw new Error("Sesija istekla.");
|
||||
}
|
||||
|
||||
const refreshData = await refreshResponse.json();
|
||||
const newAccessToken = refreshData.access;
|
||||
setToken(newAccessToken, refreshData.refresh || refreshToken);
|
||||
return newAccessToken;
|
||||
})();
|
||||
}
|
||||
|
||||
try {
|
||||
const newAccessToken = await refreshPromise;
|
||||
config.headers['Authorization'] = `Bearer ${newAccessToken}`;
|
||||
response = await fetch(url, config);
|
||||
} catch (refreshError) {
|
||||
setToken(null);
|
||||
window.location.href = '/login?session=expired';
|
||||
throw refreshError;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
let errorMessage = `API Greška [${response.status}]`;
|
||||
if (response.status === 400 && typeof errData === 'object') {
|
||||
// Ako je greška DRF validacijska, flatten-iraj je u string ili objekt
|
||||
throw { message: Object.values(errData).flat().join(', '), details: errData, status: 400 };
|
||||
}
|
||||
if (errData.detail) errorMessage = errData.detail;
|
||||
throw { message: String(errorMessage), status: response.status };
|
||||
}
|
||||
|
||||
if (options.responseType === 'blob') return await response.blob();
|
||||
if (response.status === 204 || config.method === 'DELETE') return { success: true };
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
// 401 obrađuje tihi refresh interceptor iznad (ne logiramo — nije bug).
|
||||
// 404 na ručno pozvanim provjerama (heartbeat, itd.) nije pogreška arhitekture.
|
||||
const silenced = error?.status === 401 || error?.status === 404;
|
||||
if (!silenced) {
|
||||
console.error(`Mrežni problem na ${endpoint}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (endpoint, options) => request(endpoint, { ...options, method: 'GET' }),
|
||||
post: (endpoint, body, options) => request(endpoint, { ...options, method: 'POST', body }),
|
||||
put: (endpoint, body, options) => request(endpoint, { ...options, method: 'PUT', body }),
|
||||
patch: (endpoint, body, options) => request(endpoint, { ...options, method: 'PATCH', body }),
|
||||
delete: (endpoint, options) => request(endpoint, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
116
frontend/src/stores/authStore.js
Normal file
116
frontend/src/stores/authStore.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
function getCookie(name) {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
export const $accessToken = atom(null);
|
||||
export const $user = atom(null);
|
||||
export const $authReady = atom(false);
|
||||
let authHydrated = false;
|
||||
|
||||
export function setToken(token, refreshToken = null) {
|
||||
if (token) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('access_token', token);
|
||||
document.cookie = `access_token=${token}; path=/; max-age=86400; SameSite=Lax;`;
|
||||
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=604800; SameSite=Lax;`;
|
||||
}
|
||||
}
|
||||
$accessToken.set(token);
|
||||
|
||||
if (typeof window !== 'undefined' && $user.get()?.id) {
|
||||
import('./notificationStore.js').then(({ connectNotifications }) => connectNotifications());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('todo_cache');
|
||||
document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
import('./notificationStore.js').then(({ disconnectNotifications, clearNotifications }) => {
|
||||
disconnectNotifications();
|
||||
clearNotifications();
|
||||
});
|
||||
}
|
||||
$accessToken.set(null);
|
||||
$user.set(null);
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
return !!$accessToken.get();
|
||||
}
|
||||
|
||||
export function hydrateAuthFromStorage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (authHydrated) {
|
||||
return;
|
||||
}
|
||||
authHydrated = true;
|
||||
|
||||
const token = getCookie('access_token') || localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
$accessToken.set(token);
|
||||
}
|
||||
$authReady.set(true);
|
||||
}
|
||||
|
||||
export async function loadCurrentUser() {
|
||||
const token = $accessToken.get();
|
||||
if (!token) {
|
||||
$user.set(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { api } = await import('../services/apiClient.js');
|
||||
const userData = await api.get('users/me/');
|
||||
$user.set(userData);
|
||||
return userData;
|
||||
}
|
||||
|
||||
export async function validateToken() {
|
||||
const token = $accessToken.get();
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
await loadCurrentUser();
|
||||
const { fetchNotifications, connectNotifications } = await import('./notificationStore.js');
|
||||
await fetchNotifications();
|
||||
await connectNotifications();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err?.status === 401) {
|
||||
setToken(null);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login?session=expired';
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithCredentials(email, password) {
|
||||
const { api } = await import('../services/apiClient.js');
|
||||
const response = await api.post('token/', { email, password });
|
||||
setToken(response.access, response.refresh);
|
||||
await loadCurrentUser();
|
||||
return response;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
setToken(null);
|
||||
}
|
||||
39
frontend/src/stores/clientStore.js
Normal file
39
frontend/src/stores/clientStore.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// src/stores/clientStore.js
|
||||
// CRUD store za CRM klijente — endpoint: /api/crm/clients/
|
||||
|
||||
import { atom } from 'nanostores';
|
||||
import { api } from '../services/apiClient.js';
|
||||
|
||||
export const $clients = atom([]);
|
||||
export const $clientsLoading = atom(false);
|
||||
export const $clientsError = atom(null);
|
||||
|
||||
export async function fetchClients(signal) {
|
||||
$clientsLoading.set(true);
|
||||
$clientsError.set(null);
|
||||
try {
|
||||
const data = await api.get('crm/clients/', { signal });
|
||||
$clients.set(Array.isArray(data) ? data : (data.results ?? []));
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError') $clientsError.set(err.message ?? 'Greška');
|
||||
} finally {
|
||||
$clientsLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createClient(payload) {
|
||||
const data = await api.post('crm/clients/', payload);
|
||||
$clients.set([...$clients.get(), data]);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateClient(id, payload) {
|
||||
const data = await api.patch(`crm/clients/${id}/`, payload);
|
||||
$clients.set($clients.get().map((c) => (c.id === id ? data : c)));
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteClient(id) {
|
||||
await api.delete(`crm/clients/${id}/`);
|
||||
$clients.set($clients.get().filter((c) => c.id !== id));
|
||||
}
|
||||
8
frontend/src/stores/collectionStore.js
Normal file
8
frontend/src/stores/collectionStore.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
// Pohranjujemo cijeli objekt kolekcije (uključujući račune)
|
||||
export const $currentCollection = atom(null);
|
||||
|
||||
export function updateCollection(data) {
|
||||
$currentCollection.set(data);
|
||||
}
|
||||
3
frontend/src/stores/counter.js
Normal file
3
frontend/src/stores/counter.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const $counter = atom(0);
|
||||
57
frontend/src/stores/craneSerialStore.js
Normal file
57
frontend/src/stores/craneSerialStore.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const $craneSerialByVehicle = atom({});
|
||||
|
||||
function normalizeVehicleId(vehicleId) {
|
||||
if (vehicleId == null) {
|
||||
return '';
|
||||
}
|
||||
return String(vehicleId);
|
||||
}
|
||||
|
||||
function normalizeSerial(serial) {
|
||||
return String(serial || '').trim();
|
||||
}
|
||||
|
||||
export function cacheCraneSerial(vehicleId, serial) {
|
||||
const key = normalizeVehicleId(vehicleId);
|
||||
const value = normalizeSerial(serial);
|
||||
if (!key || !value) {
|
||||
return;
|
||||
}
|
||||
$craneSerialByVehicle.set({
|
||||
...$craneSerialByVehicle.get(),
|
||||
[key]: value,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCachedCraneSerial(vehicleId) {
|
||||
const key = normalizeVehicleId(vehicleId);
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
return normalizeSerial($craneSerialByVehicle.get()[key]);
|
||||
}
|
||||
|
||||
export function cacheCraneSerialsFromVehicles(vehicles) {
|
||||
if (!Array.isArray(vehicles) || vehicles.length === 0) {
|
||||
return;
|
||||
}
|
||||
const current = $craneSerialByVehicle.get();
|
||||
const next = { ...current };
|
||||
let changed = false;
|
||||
for (const vehicle of vehicles) {
|
||||
const key = normalizeVehicleId(vehicle?.id);
|
||||
const value = normalizeSerial(vehicle?.crane_serial_number);
|
||||
if (!key || !value) {
|
||||
continue;
|
||||
}
|
||||
if (next[key] !== value) {
|
||||
next[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
$craneSerialByVehicle.set(next);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
44
frontend/src/stores/invoiceStore.js
Normal file
44
frontend/src/stores/invoiceStore.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/stores/invoiceStore.js
|
||||
import { atom } from 'nanostores';
|
||||
import { api } from '../services/apiClient';
|
||||
import { showToast } from './toastStore';
|
||||
|
||||
export const $invoices = atom([]);
|
||||
export const $clients = atom([]);
|
||||
export const $invoicesLoading = atom(false);
|
||||
|
||||
export async function fetchClients() {
|
||||
try {
|
||||
const data = await api.get('invoicing/clients/');
|
||||
$clients.set(Array.isArray(data) ? data : (data?.results ?? []));
|
||||
} catch {
|
||||
// Klijenti su opcionalni — ne blokiramo UI ako nisu dostupni
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchInvoices() {
|
||||
$invoicesLoading.set(true);
|
||||
try {
|
||||
const data = await api.get('invoicing/invoices/');
|
||||
$invoices.set(Array.isArray(data) ? data : (data?.results ?? []));
|
||||
} catch (error) {
|
||||
if (error?.status !== 401) {
|
||||
showToast('Greška pri dohvatu faktura.', 'error');
|
||||
}
|
||||
} finally {
|
||||
$invoicesLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createInvoice(data) {
|
||||
const response = await api.post('invoicing/invoices/', data);
|
||||
$invoices.set([response, ...$invoices.get()]);
|
||||
showToast(`Faktura ${response.invoice_number} je uspješno kreirana.`, 'success');
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function createTransaction(data) {
|
||||
const response = await api.post('invoicing/transaction/', data);
|
||||
showToast('Plaćanje je zabilježeno.', 'success');
|
||||
return response;
|
||||
}
|
||||
41
frontend/src/stores/networkStore.js
Normal file
41
frontend/src/stores/networkStore.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const $isOffline = atom(false);
|
||||
|
||||
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
|
||||
|
||||
// Heartbeat endpoint: OPTIONS /api/token/
|
||||
// DRF odgovara bez 405 greške i dovoljno je za provjeru dostupnosti backenda.
|
||||
const HEARTBEAT_URL = new URL('token/', API_BASE).toString();
|
||||
|
||||
async function provjeriStvarnuVezu() {
|
||||
try {
|
||||
const response = await fetch(HEARTBEAT_URL, {
|
||||
method: 'OPTIONS',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
// Svaki HTTP odgovor (200, 204, 401...) = server živ.
|
||||
// Samo 5xx ili network error = offline.
|
||||
if (response.status >= 500) {
|
||||
throw new Error('Server nedostupan');
|
||||
}
|
||||
|
||||
$isOffline.set(false);
|
||||
} catch {
|
||||
$isOffline.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => {
|
||||
provjeriStvarnuVezu();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
$isOffline.set(true);
|
||||
});
|
||||
|
||||
provjeriStvarnuVezu();
|
||||
setInterval(provjeriStvarnuVezu, 10000);
|
||||
}
|
||||
149
frontend/src/stores/notificationStore.js
Normal file
149
frontend/src/stores/notificationStore.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import { atom, computed } from 'nanostores';
|
||||
import Pusher from 'pusher-js';
|
||||
import { api } from '../services/apiClient';
|
||||
import { $accessToken, $user } from './authStore';
|
||||
import { showToast } from './toastStore';
|
||||
|
||||
export const $notifications = atom([]);
|
||||
export const $isRealtimeConnected = atom(false);
|
||||
export const $unreadNotifications = computed(
|
||||
$notifications,
|
||||
(notifications) => notifications.filter((notification) => !notification.is_read).length
|
||||
);
|
||||
|
||||
let pusherClient = null;
|
||||
let userChannel = null;
|
||||
|
||||
function getPusherConfig() {
|
||||
const key = import.meta.env.PUBLIC_PUSHER_KEY || __PUSHER_KEY__;
|
||||
const cluster = import.meta.env.PUBLIC_PUSHER_CLUSTER || __PUSHER_CLUSTER__;
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
|
||||
const authEndpoint = new URL('fleet/pusher-auth/', apiUrl).toString();
|
||||
|
||||
if (!key || !cluster) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { key, cluster, authEndpoint };
|
||||
}
|
||||
|
||||
export async function fetchNotifications() {
|
||||
try {
|
||||
const data = await api.get('fleet/notifications/');
|
||||
$notifications.set(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error('Greška pri dohvaćanju notifikacija:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(notificationId) {
|
||||
try {
|
||||
const updated = await api.post(`fleet/notifications/${notificationId}/mark-read/`);
|
||||
$notifications.set(
|
||||
$notifications.get().map((notification) =>
|
||||
notification.id === notificationId ? { ...notification, ...updated } : notification
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
showToast('Neuspješno označavanje notifikacije kao pročitane.', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAllNotificationsAsRead() {
|
||||
try {
|
||||
await api.post('fleet/notifications/mark-all-read/');
|
||||
$notifications.set(
|
||||
$notifications.get().map((notification) => ({
|
||||
...notification,
|
||||
is_read: true,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
showToast('Neuspješno označavanje svih notifikacija kao pročitanih.', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function attachChannelHandlers(channel) {
|
||||
channel.bind('vehicle.notification.created', (payload) => {
|
||||
const next = {
|
||||
id: payload.id,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
level: payload.level || 'info',
|
||||
is_read: payload.is_read ?? false,
|
||||
is_sent: false,
|
||||
created_at: payload.created_at || new Date().toISOString(),
|
||||
metadata: payload.metadata || null,
|
||||
};
|
||||
|
||||
const existing = $notifications.get();
|
||||
if (existing.some((notification) => notification.id === next.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notifications.set([next, ...existing]);
|
||||
showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectNotifications() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = $user.get();
|
||||
const token = $accessToken.get();
|
||||
const pusherConfig = getPusherConfig();
|
||||
|
||||
if (!user?.id || !token || !pusherConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (userChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
pusherClient = new Pusher(pusherConfig.key, {
|
||||
cluster: pusherConfig.cluster,
|
||||
channelAuthorization: {
|
||||
endpoint: pusherConfig.authEndpoint,
|
||||
transport: 'ajax',
|
||||
headersProvider: () => {
|
||||
const currentToken = $accessToken.get();
|
||||
return currentToken ? { Authorization: `Bearer ${currentToken}` } : {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const channelName = `private-user-${user.id}`;
|
||||
userChannel = pusherClient.subscribe(channelName);
|
||||
attachChannelHandlers(userChannel);
|
||||
|
||||
pusherClient.connection.bind('connected', () => {
|
||||
$isRealtimeConnected.set(true);
|
||||
});
|
||||
pusherClient.connection.bind('disconnected', () => {
|
||||
$isRealtimeConnected.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnectNotifications() {
|
||||
if (userChannel && pusherClient) {
|
||||
pusherClient.unsubscribe(userChannel.name);
|
||||
}
|
||||
|
||||
userChannel = null;
|
||||
|
||||
if (pusherClient) {
|
||||
pusherClient.disconnect();
|
||||
}
|
||||
|
||||
pusherClient = null;
|
||||
$isRealtimeConnected.set(false);
|
||||
}
|
||||
|
||||
export function clearNotifications() {
|
||||
$notifications.set([]);
|
||||
}
|
||||
20
frontend/src/stores/racunStore.js
Normal file
20
frontend/src/stores/racunStore.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// src/stores/racunStore.js
|
||||
// NAPOMENA: Stari endpoint 'todo/racuni/' je zamijenjen s 'invoicing/invoices/'
|
||||
import { atom } from 'nanostores';
|
||||
import { api } from '../services/apiClient';
|
||||
import { showToast } from './toastStore';
|
||||
|
||||
export const $racuni = atom([]);
|
||||
export const $isLeapLoading = atom(false);
|
||||
|
||||
export async function fetchRacuneZaKlijenta(clientId) {
|
||||
if (!clientId) return;
|
||||
try {
|
||||
const data = await api.get(`invoicing/invoices/?client=${clientId}`);
|
||||
$racuni.set(Array.isArray(data) ? data : (data?.results ?? []));
|
||||
} catch (error) {
|
||||
if (error?.status !== 401 && error?.status !== 404) {
|
||||
showToast('Greška pri dohvatu faktura.', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
85
frontend/src/stores/serviceContextStore.js
Normal file
85
frontend/src/stores/serviceContextStore.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// src/stores/serviceContextStore.js
|
||||
//
|
||||
// Reaktivni store za "Servisni kontekst" — odabrani kupac + vozilo.
|
||||
// Atomi su inicijalno null; pozovi hydrateServiceContext() unutar useEffect.
|
||||
// ServiceContextSelector.jsx i FleetDashboardShell.jsx dijele isti izvor istine.
|
||||
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
const LS_CLIENT = 'topbar_selected_client';
|
||||
const LS_VEHICLE = 'topbar_selected_vehicle';
|
||||
|
||||
export const $selectedClientId = atom(null);
|
||||
export const $selectedVehicleId = atom(null);
|
||||
|
||||
function emitServiceContextChanged(clientId, vehicleId) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent('service-context:changed', {
|
||||
detail: {
|
||||
clientId: clientId || null,
|
||||
vehicleId: vehicleId || null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
|
||||
export function hydrateServiceContext() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const client = localStorage.getItem(LS_CLIENT);
|
||||
const vehicle = localStorage.getItem(LS_VEHICLE);
|
||||
$selectedClientId.set(client || null);
|
||||
$selectedVehicleId.set(vehicle || null);
|
||||
emitServiceContextChanged(client || null, vehicle || null);
|
||||
}
|
||||
|
||||
/** Postavi novog kupca i resetiraj odabrano vozilo. */
|
||||
export function setSelectedClient(id) {
|
||||
$selectedClientId.set(id || null);
|
||||
$selectedVehicleId.set(null);
|
||||
if (id) {
|
||||
localStorage.setItem(LS_CLIENT, id);
|
||||
} else {
|
||||
localStorage.removeItem(LS_CLIENT);
|
||||
}
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
emitServiceContextChanged(id || null, null);
|
||||
}
|
||||
|
||||
/** Postavi odabrano vozilo. */
|
||||
export function setSelectedVehicle(id) {
|
||||
$selectedVehicleId.set(id || null);
|
||||
if (id) {
|
||||
localStorage.setItem(LS_VEHICLE, id);
|
||||
} else {
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
}
|
||||
emitServiceContextChanged($selectedClientId.get(), id || null);
|
||||
}
|
||||
|
||||
/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
|
||||
export function setServiceContext(clientId, vehicleId) {
|
||||
$selectedClientId.set(clientId || null);
|
||||
$selectedVehicleId.set(vehicleId || null);
|
||||
|
||||
if (clientId) {
|
||||
localStorage.setItem(LS_CLIENT, clientId);
|
||||
} else {
|
||||
localStorage.removeItem(LS_CLIENT);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
localStorage.setItem(LS_VEHICLE, vehicleId);
|
||||
} else {
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
}
|
||||
emitServiceContextChanged(clientId || null, vehicleId || null);
|
||||
}
|
||||
|
||||
/** Poništi cijeli kontekst. */
|
||||
export function clearServiceContext() {
|
||||
$selectedClientId.set(null);
|
||||
$selectedVehicleId.set(null);
|
||||
localStorage.removeItem(LS_CLIENT);
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
emitServiceContextChanged(null, null);
|
||||
}
|
||||
63
frontend/src/stores/taskStore.js
Normal file
63
frontend/src/stores/taskStore.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// src/stores/taskStore.js
|
||||
import { atom } from 'nanostores';
|
||||
import { api } from '../services/apiClient';
|
||||
import { showToast } from './toastStore';
|
||||
|
||||
export const $tasks = atom([]);
|
||||
export const $tasksLoading = atom(false);
|
||||
|
||||
const STATUS_LABELS = {
|
||||
aktivan: 'Aktivan',
|
||||
servis: 'U tijeku',
|
||||
neaktivan: 'Otkazano',
|
||||
};
|
||||
|
||||
export function getStatusLabel(status) {
|
||||
return STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
export function isTaskActive(task) {
|
||||
const status = String(task?.status || '').toLowerCase();
|
||||
return status === 'aktivan' || status === 'servis';
|
||||
}
|
||||
|
||||
export async function fetchTasks() {
|
||||
$tasksLoading.set(true);
|
||||
try {
|
||||
const data = await api.get('tasks/tasks/');
|
||||
$tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
|
||||
} catch (error) {
|
||||
if (error?.status !== 401) {
|
||||
showToast('Greška pri dohvatu zadataka.', 'error');
|
||||
}
|
||||
} finally {
|
||||
$tasksLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTask(data) {
|
||||
const response = await api.post('tasks/tasks/', data);
|
||||
$tasks.set([response, ...$tasks.get()]);
|
||||
showToast('Zadatak je uspješno kreiran.', 'success');
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function updateTaskStatus(taskId, status) {
|
||||
const response = await api.patch(`tasks/tasks/${taskId}/`, { status });
|
||||
$tasks.set($tasks.get().map((t) => (String(t.id) === String(taskId) ? response : t)));
|
||||
showToast(`Status zadatka promijenjen u: ${getStatusLabel(status)}`, 'success');
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function updateTask(taskId, changes) {
|
||||
const response = await api.patch(`tasks/tasks/${taskId}/`, changes);
|
||||
$tasks.set($tasks.get().map((t) => (String(t.id) === String(taskId) ? response : t)));
|
||||
showToast('Task je uspješno ažuriran.', 'success');
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId) {
|
||||
await api.delete(`tasks/tasks/${taskId}/`);
|
||||
$tasks.set($tasks.get().filter((t) => String(t.id) !== String(taskId)));
|
||||
showToast('Zadatak je deaktiviran.', 'success');
|
||||
}
|
||||
41
frontend/src/stores/toastStore.js
Normal file
41
frontend/src/stores/toastStore.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/stores/toastStore.js
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const $toast = atom(null);
|
||||
export const $toasts = atom([]);
|
||||
|
||||
function getToastList() {
|
||||
const current = $toasts.get();
|
||||
return Array.isArray(current) ? current : [];
|
||||
}
|
||||
|
||||
export function showToast(message, type = 'error', timeout = 4500) {
|
||||
const toast = { message, type, timeout, id: Date.now() + Math.random() };
|
||||
$toast.set(toast);
|
||||
$toasts.set([toast, ...getToastList()].slice(0, 5));
|
||||
return toast.id;
|
||||
}
|
||||
|
||||
export function clearToast(id = null) {
|
||||
if (id == null) {
|
||||
const items = getToastList();
|
||||
if (items.length <= 1) {
|
||||
$toast.set(null);
|
||||
$toasts.set([]);
|
||||
return;
|
||||
}
|
||||
const next = items.slice(1);
|
||||
$toasts.set(next);
|
||||
$toast.set(next[0] ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = getToastList().filter((item) => item.id !== id);
|
||||
$toasts.set(next);
|
||||
$toast.set(next[0] ?? null);
|
||||
}
|
||||
|
||||
export function clearAllToasts() {
|
||||
$toast.set(null);
|
||||
$toasts.set([]);
|
||||
}
|
||||
242
frontend/src/stores/todoStore.js
Normal file
242
frontend/src/stores/todoStore.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// src/stores/todoStore.js
|
||||
import { atom, computed } from 'nanostores';
|
||||
import { api } from '../services/apiClient';
|
||||
import { showToast } from './toastStore';
|
||||
|
||||
export const $todos = atom([]);
|
||||
export const $collections = atom([]);
|
||||
export const $collectionIdFilter = atom('');
|
||||
export const $refreshSignal = atom(0);
|
||||
|
||||
/**
|
||||
* 📥 Dohvaća TODO stavke I kolekcije s backenda
|
||||
*/
|
||||
export async function fetchTodos() {
|
||||
try {
|
||||
// Dohvaćamo oboje paralelno
|
||||
const [todosData, collectionsData] = await Promise.all([
|
||||
api.get('todo/todos/'),
|
||||
api.get('todo/collections/')
|
||||
]);
|
||||
|
||||
$todos.set(todosData);
|
||||
$collections.set(collectionsData); // 💡 Puni store s kolekcijama
|
||||
} catch (error) {
|
||||
console.error('Greška pri dohvaćanju podataka:', error);
|
||||
showToast('Neuspješno dohvaćanje podataka.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ➕ Dodaje novu stavku (podržava FormData sa slikom)
|
||||
*/
|
||||
export async function addTodo(formData) {
|
||||
try {
|
||||
const newTodo = await api.post('todo/todos/', formData);
|
||||
|
||||
// 🚀 MEHATRONIČKA DIJAGNOSTIKA: Pogledajmo što točno Django isporučuje u konzolu
|
||||
console.log("🚀 DJANGO_RESPONSE_RAW:", newTodo);
|
||||
|
||||
// Osiguravamo da radimo s čistim nizom
|
||||
const currentTodos = Array.isArray($todos.get()) ? $todos.get() : [];
|
||||
|
||||
// Dodajemo na vrh niza i ažuriramo atom
|
||||
$todos.set([newTodo, ...currentTodos]);
|
||||
|
||||
showToast('Zadatak uspješno dodan.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Greška pri izradi:', error);
|
||||
showToast('Greška prilikom izrade stavke.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ❌ Pesimistično brisanje stavke s UI-ja nakon potvrde backenda
|
||||
*/
|
||||
export async function deleteTodo(id) {
|
||||
try {
|
||||
// 🔥 POPRAVLJENO: DRF ModelViewSet briše preko DELETE metode na 'todo/todos/[id]/'
|
||||
await api.delete(`todo/todos/${id}/`);
|
||||
|
||||
// Tek ovdje vršimo mutaciju klijentskog stanja (Pesimistični UI update)
|
||||
$todos.set($todos.get().filter(todo => todo.id !== id));
|
||||
showToast('Stavka uspješno obrisana.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Greška pri brisanju:', error);
|
||||
showToast('Brisanje nije uspjelo na backendu.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 Promjena statusa zadatka (Završeno / Aktivno)
|
||||
*/
|
||||
export async function toggleTodoStatus(id, currentStatus) {
|
||||
try {
|
||||
// 🔥 POPRAVLJENO: Putanja za ažuriranje u ruteru je 'todo/todos/[id]/'
|
||||
const updatedTodo = await api.patch(`todo/todos/${id}/`, {
|
||||
is_completed: !currentStatus
|
||||
});
|
||||
|
||||
// Tek nakon uspješnog odgovora s backenda, ažuriramo klijentsko stanje s novim podacima
|
||||
$todos.set(
|
||||
$todos.get().map(todo => (todo.id === id ? updatedTodo : todo))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Greška pri ažuriranju statusa:', error);
|
||||
showToast('Nije uspjelo ažuriranje statusa zadatka.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📝 Uređivanje postojećeg zadatka (podržava FormData sa slikama)
|
||||
* Nakon uspješnog odgovora s backenda, ažuriramo objekt u klijentskoj memoriji
|
||||
* što automatski i trenutno osvježava TodoList na Dashboardu bez F5 osvježavanja!
|
||||
*/
|
||||
export async function updateTodo(id, formData) {
|
||||
try {
|
||||
// DRF ModelViewSet prima izmjene na 'todo/todos/[id]/'
|
||||
const updatedTodo = await api.patch(`todo/todos/${id}/`, formData);
|
||||
|
||||
// ⚡ REAKTIVNA SINKRONIZACIJA: Mapiramo kroz trenutno stanje u memoriji
|
||||
$todos.set(
|
||||
$todos.get().map(todo => (todo.id === id ? updatedTodo : todo))
|
||||
);
|
||||
|
||||
showToast('✓ Servisni nalog uspješno ažuriran.', 'success');
|
||||
return updatedTodo;
|
||||
} catch (error) {
|
||||
console.error('Greška pri uređivanju zadatka:', error);
|
||||
showToast('Nije uspjelo spremanje izmjena na poslužitelj.', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 💡 KREIRANJE IZVEDENOG STOREA
|
||||
* $filteredTodos automatski sluša $todos i collectionId.
|
||||
* Čim se $todos promijeni, ovaj se store automatski re-izračunava.
|
||||
*/
|
||||
export const $filteredTodos = computed(
|
||||
[$todos, $collectionIdFilter],
|
||||
(todos, collectionId) => {
|
||||
// 1. Ako na Dashboardu uopće nije odabran filter (prikazuju se svi zadatci), vrati sve odmah!
|
||||
if (!collectionId) return todos;
|
||||
|
||||
const targetId = parseInt(collectionId, 10);
|
||||
|
||||
return todos.filter(todo => {
|
||||
// Ako zadatak uopće nema definiranu kolekciju, preskoči ga sigurno
|
||||
if (!todo.collection && !todo.collection_id) return false;
|
||||
|
||||
// Scenarij A: Django je vratio ugniježđeni objekt (todo.collection.id)
|
||||
if (todo.collection && typeof todo.collection === 'object') {
|
||||
return todo.collection.id === targetId;
|
||||
}
|
||||
|
||||
// Scenarij B: Django je vratio samo ravan broj ili string ID (todo.collection ili todo.collection_id)
|
||||
const directId = todo.collection_id || todo.collection;
|
||||
return parseInt(directId, 10) === targetId;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 🔄 Posebna funkcija za osvježavanje samo kolekcija
|
||||
*/
|
||||
export async function fetchCollections() {
|
||||
try {
|
||||
const data = await api.get('todo/collections/');
|
||||
$collections.set(data);
|
||||
} catch (error) {
|
||||
showToast('Neuspješno osvježavanje kolekcija.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerRefresh = async () => {
|
||||
$refreshSignal.set($refreshSignal.get() + 1);
|
||||
|
||||
await fetchTodos();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* ⚡ KLIJENTSKO SKUPNO ZATVARANJE NALOGA (Bulk Complete)
|
||||
* Budući da backend nema namjenski bulk endpoint, filtriramo sve nezavršene zadatke
|
||||
* unutar ove kolekcije na klijentu i asinkrono ih paralelno zatvaramo kroz postojeće PATCH rute.
|
||||
*/
|
||||
export async function bulkCompleteCollectionTodos(collectionId) {
|
||||
try {
|
||||
// 1. Pronalazimo sve zadatke koji pripadaju toj kolekciji i koji još nisu završeni
|
||||
const nezavrseniTodos = $todos.get().filter(todo => {
|
||||
const colId = todo.collection && typeof todo.collection === 'object'
|
||||
? todo.collection.id
|
||||
: (todo.collection_id || todo.collection);
|
||||
return parseInt(colId, 10) === parseInt(collectionId, 10) && !todo.is_completed;
|
||||
});
|
||||
|
||||
if (nezavrseniTodos.length === 0) {
|
||||
showToast('Svi nalozi za ovaj stroj su već zatvoreni.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(`Pokrećem zatvaranje ${nezavrseniTodos.length} naloga...`, 'success');
|
||||
|
||||
// 2. Kreiramo niz asinkronih PATCH zahtjeva (Paralelno izvršavanje unutar Docker mreže)
|
||||
const patchZahtjevi = nezavrseniTodos.map(todo =>
|
||||
api.patch(`todo/todos/${todo.id}/`, { is_completed: true })
|
||||
);
|
||||
|
||||
// 3. Čekamo da se svi zahtjevi izvrše na PostgreSQL bazi
|
||||
const azuriraniRezultati = await Promise.all(patchZahtjevi);
|
||||
|
||||
// 4. Sinkroniziramo globalni atom sa svježim podatcima koje je vratio DRF update
|
||||
const trenutniTodos = $todos.get();
|
||||
const novaMrezaZadataka = trenutniTodos.map(todo => {
|
||||
const ponovniZapis = azuriraniRezultati.find(r => r.id === todo.id);
|
||||
return ponovniZapis ? ponovniZapis : todo;
|
||||
});
|
||||
|
||||
$todos.set(novaMrezaZadataka);
|
||||
showToast('✓ Svi servisni nalozi uspješno sinkronizirani i zatvoreni.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Greška pri klijentskom skupnom zatvaranju:', error);
|
||||
showToast('Nije uspjelo masovno zatvaranje svih naloga stroja.', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Primjenjuje predložak na odabranu kolekciju.
|
||||
* @param {number} collectionId - ID kolekcije (stroj/flota)
|
||||
* @param {string} templateKey - Ključ predloška (npr. 'redovni_servis_1050')
|
||||
*/
|
||||
export async function primijeniServisniPredlozak(collectionId, templateKey) {
|
||||
try {
|
||||
const response = await fetch(`/api/todo/collections/${collectionId}/primijeni-predlozak/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Ovdje ide tvoj auth header (JWT ili Session)
|
||||
},
|
||||
body: JSON.stringify({ template_key: templateKey })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Greška pri primjeni predloška.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Ovdje možeš ažurirati lokalni store s novim zadacima
|
||||
// $kolekcije.set([...$collections.get(), ...data.todos]);
|
||||
$collections.set([...trenutno, ...data.todos]);
|
||||
|
||||
|
||||
return data; // Vraća listu novokreiranih zadataka
|
||||
} catch (error) {
|
||||
console.error("Kritična greška u servisu:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
8
frontend/src/stores/uiStore.js
Normal file
8
frontend/src/stores/uiStore.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/stores/uiStore.js
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
// Pohranjujemo ID zadatka koji je trenutno otvoren. Ako je null, modal je zatvoren.
|
||||
export const $activeTodoId = atom(null);
|
||||
|
||||
export const openTodoModal = (id) => $activeTodoId.set(id);
|
||||
export const closeTodoModal = () => $activeTodoId.set(null);
|
||||
61
frontend/src/styles/global.css
Normal file
61
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* src/styles/global.css */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 1. Reci Tailwindu v4 da skenira Flowbite komponente unutar node_modules */
|
||||
@source "../../node_modules/flowbite/**/*.js";
|
||||
|
||||
/* 2. Učitaj Flowbite plugin koristeći v4 sintaksu */
|
||||
@plugin "flowbite/plugin";
|
||||
|
||||
@theme {
|
||||
--color-canvas-base: var(--app-canvas-base);
|
||||
--color-canvas-elevated: var(--app-canvas-elevated);
|
||||
--color-canvas-deep: var(--app-canvas-deep);
|
||||
|
||||
--color-brand-primary: var(--app-brand-primary);
|
||||
--color-brand-accent: var(--app-brand-accent);
|
||||
--color-brand-accent-bright: var(--app-brand-accent-bright);
|
||||
|
||||
--color-text-main: var(--app-text-main);
|
||||
--color-text-muted: var(--app-text-muted);
|
||||
--color-border-hairline: var(--app-border-hairline);
|
||||
|
||||
/* Registracija globalne animacije */
|
||||
--animate-blob-float: blobFloat 10s ease-in-out infinite;
|
||||
|
||||
@keyframes blobFloat {
|
||||
0%, 100% { transform: translateY(0px) scale(1) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) scale(1.02) rotate(1deg); }
|
||||
}
|
||||
}
|
||||
|
||||
/* 🌙 DARK MOD: Kada 'html' element dobije klasu .dark, varijable poprimaju Blade Runner estetiku */
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ==============================================================================
|
||||
🚀 DRY UTILITY KOMPONENTE (Tailwind v4 Sintaksa)
|
||||
============================================================================== */
|
||||
|
||||
/* Stvaramo unificiranu bazu gumba koja radi kroz Astro i Preact */
|
||||
@utility btn-erp {
|
||||
@apply inline-flex items-center justify-center font-sans font-bold uppercase tracking-wider text-xs px-4 py-2.5 rounded-md shadow-sm transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* Primarni gumb (Popravljen s ispravnom v4 prozirnošću na hoveru) */
|
||||
@utility btn-erp-primary {
|
||||
/* ✅ POPRAVLJENO: bg-brand-primary/90 postavlja prozirnost izravno na boju tijekom hover stanja */
|
||||
@apply btn-erp bg-brand-primary text-white hover:bg-brand-primary/90 active:scale-[0.98];
|
||||
}
|
||||
|
||||
/* Opcionalni Outline gumb */
|
||||
@utility btn-erp-outline {
|
||||
@apply btn-erp border border-border-hairline text-text-muted hover:bg-canvas-deep;
|
||||
}
|
||||
|
||||
/* Modal blur efekt — blurira main content, ali ne i Navbar */
|
||||
#main-content {
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
body.modal-open #main-content {
|
||||
filter: blur(4px);
|
||||
}
|
||||
5
frontend/tsconfig.json
Normal file
5
frontend/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user