ne radi vite server

This commit is contained in:
2026-05-23 21:52:15 +00:00
parent 80f9ba3879
commit 668195e642
32 changed files with 2096 additions and 680 deletions

View File

@@ -10,13 +10,19 @@ export default defineConfig({
}),
server: {
host: true,
host: '0.0.0.0',
port: 4321,
},
image: {
// Dodaj domene s kojih Astro smije povlačiti i optimizirati slike
domains: ['localhost', '127.0.0.1','v003-backend.captain.mitteworkspace.cloud'],
remotePatterns: [
{
protocol: 'https',
hostname: 'frontend-operativa.local.mitteworkspace.cloud',
port: '',
pathname: '/**',
},
],
},
build: {
@@ -24,11 +30,22 @@ export default defineConfig({
},
vite: {
build: {
// Smanji intenzitet optimizacije tijekom dev-a
minify: false,
cssMinify: false,
},
plugins: [tailwindcss()],
// KLJUČNO ZA TAILWIND v4 + NODE standalone SSR:
ssr: {
// Prisiljava Vite da uključi Tailwind v4 stilove u serverski bundle
noExternal: ['tailwindcss', '@tailwindcss/vite']
},
server: {
watch: {
ignored: ['**/node_modules/**', '**/dist/**'],
},
allowedHosts: ['.mitteworkspace.cloud']
}
}
});

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@astrojs/node": "^10.0.6",
"@tailwindcss/vite": "^4.2.4",
"astro": "^6.1.9",
"astro": "^6.3.7",
"flowbite": "^4.0.1",
"photoswipe": "^5.4.4",
"tailwindcss": "^4.2.4"
@@ -203,17 +203,6 @@
"node": ">= 20.12.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -224,16 +213,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -1161,40 +1140,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1205,270 +1156,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT",
"peer": true
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
@@ -2210,16 +1897,6 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2414,80 +2091,6 @@
"sharp": "^0.34.0"
}
},
"node_modules/astro/node_modules/vite": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -5304,40 +4907,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.132.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.2",
"@rolldown/binding-darwin-arm64": "1.0.2",
"@rolldown/binding-darwin-x64": "1.0.2",
"@rolldown/binding-freebsd-x64": "1.0.2",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
"@rolldown/binding-linux-arm64-musl": "1.0.2",
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
"@rolldown/binding-linux-x64-gnu": "1.0.2",
"@rolldown/binding-linux-x64-musl": "1.0.2",
"@rolldown/binding-openharmony-arm64": "1.0.2",
"@rolldown/binding-wasm32-wasi": "1.0.2",
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
"@rolldown/binding-win32-x64-msvc": "1.0.2"
}
},
"node_modules/rollup": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
@@ -5643,9 +5212,9 @@
}
},
"node_modules/tinyexec": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -6013,17 +5582,17 @@
}
},
"node_modules/vite": {
"version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.2",
"tinyglobby": "^0.2.16"
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
@@ -6039,10 +5608,9 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
@@ -6055,18 +5623,15 @@
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},

View File

@@ -2,6 +2,9 @@
"name": "poslovanje",
"type": "module",
"version": "0.0.1",
"overrides": {
"vite": "^7.0.0"
},
"engines": {
"node": ">=22.12.0"
},
@@ -10,12 +13,12 @@
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"start": "node ./dist/server/entry.mjs"
"start": "NODE_OPTIONS=--max-old-space-size=4096 npm run dev"
},
"dependencies": {
"@astrojs/node": "^10.0.6",
"@tailwindcss/vite": "^4.2.4",
"astro": "^6.1.9",
"astro": "^6.3.7",
"flowbite": "^4.0.1",
"photoswipe": "^5.4.4",
"tailwindcss": "^4.2.4"

9
001.FRONTEND/public/css/all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -54,7 +54,9 @@ import Button from "./Button.astro";
</div>
<script>
import { login } from "../lib/api";
import { login, fetchCurrentUser } from "../lib/api";
// 🚀 Ponovno uvozimo centraliziranu funkciju za rute
import { dohvatiRutuPoUlozi } from "../utils/auth";
const loginForm = document.querySelector('#login-form') as HTMLFormElement;
const btn = document.querySelector('#login-btn') as HTMLButtonElement;
@@ -64,10 +66,8 @@ import Button from "./Button.astro";
if (!btn) return;
// Pohranjujemo originalni sadržaj (ikonu + tekst)
const originalContent = btn.innerHTML;
// Vizualni feedback (Loading stanje)
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-3"></i> AUTENTIFIKACIJA...';
@@ -76,25 +76,32 @@ import Button from "./Button.astro";
const password = formData.get('password') as string;
try {
// 1. Prijava i pohrana JWT tokena
const result = await login(email, password);
if (result.success) {
// 2. Dohvat profila ulogiranog korisnika
const user = await fetchCurrentUser();
sessionStorage.setItem('pending_toast', JSON.stringify({
type: 'success',
message: 'Pristup odobren. Dobrodošli u sustav.'
}));
window.location.href = '/';
// 3. 🚀 PREUSMJERAVANJE PREKO POMOĆNE FUNKCIJE:
// Namjerno NE prosljeđujemo user.id jer želimo da serviser sleti na opću /operativa/serviseri stazu
const ciljanaRuta = dohvatiRutuPoUlozi(user?.uloga);
window.location.href = ciljanaRuta;
} else {
// Poziv tvog global-toast sustava
// @ts-ignore
window.showToast?.(result.error || "Pristup odbijen: Neispravni podaci.", "error");
// Vraćanje gumba u normalu
btn.disabled = false;
btn.innerHTML = originalContent;
}
} catch (err) {
// @ts-ignore
console.error("Login krah na klijentu:", err);
// @ts-ignore
window.showToast?.("Sustav nedostupan. Provjerite vezu.", "error");
btn.disabled = false;
btn.innerHTML = originalContent;

View File

@@ -1,59 +1,193 @@
---
// src/components/NaslovList.astro
// src/components/RadniNalogLista.astro
import NaslovList from "./NaslovList.astro";
interface Props {
naslov: string;
ukupno: number;
label1: string;
count1: number;
filter1: string;
label2: string;
count2: number;
filter2: string;
label3?: string;
count3?: number;
filter3?: string;
limit?: number;
naslov?: string;
prikaziNaslov?: boolean;
}
const { naslov, ukupno, label1, count1, filter1, label2, count2, filter2, label3, count3, filter3 } = Astro.props;
const {
limit = 0,
naslov = "Zadnje aktivnosti",
prikaziNaslov = true,
} = Astro.props;
---
<div class="flex flex-row justify-between items-center w-full min-h-[40px]">
<h2 id="filter-title" class="p-0 m-0 text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight italic leading-none">
{naslov}
</h2>
<div class="flex flex-wrap justify-end items-center gap-2 lg:gap-3">
<div
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border border-emerald-100 dark:border-emerald-800/50 cursor-pointer transition-all active:scale-95 hover:border-emerald-400"
data-filter={filter1}
>
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
<span class="text-[9px] lg:text-[10px] font-black uppercase text-emerald-700 dark:text-emerald-400 whitespace-nowrap">
{label1}: {count1}
</span>
</div>
<div class="space-y-6 group" id="radni-nalozi-sekcija" data-state="loading">
{prikaziNaslov && (
<NaslovList
naslov={naslov}
ukupno={0}
label1="Planirano"
count1={0}
filter1="planirano"
label2="U radu"
count2={0}
filter2="u_radu"
label3="Završeno"
count3={0}
filter3="zavrseno"
/>
)}
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
<div
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-100 dark:border-red-800/50 cursor-pointer transition-all active:scale-95 hover:border-red-400"
data-filter={filter2}
id="nalozi-loader"
class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse group-data-[state=loaded]:hidden group-data-[state=error]:hidden"
>
<div class="w-2 h-2 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-[9px] lg:text-[10px] font-black uppercase text-red-700 dark:text-red-400 whitespace-nowrap">
{label2}: {count2}
</span>
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija radnih naloga...
</div>
{label3 && count3 !== undefined && filter3 && (
<div
class="stat-card flex items-center gap-2 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-900/20 rounded-xl border border-gray-100 dark:border-gray-800/50 cursor-pointer transition-all active:scale-95 hover:border-gray-400"
data-filter={filter3}
>
<div class="w-2 h-2 rounded-full bg-gray-400"></div>
<span class="text-[9px] lg:text-[10px] font-black uppercase text-gray-700 dark:text-gray-400 whitespace-nowrap">
{label3}: {count3}
</span>
<div id="nalozi-list" class="space-y-0">
<div id="nalozi-placeholder" class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest group-data-[state=loaded]:hidden">
Nema zapisa za odabrani status
</div>
)}
</div>
</div>
</div>
</div>
<span id="rn-bridge" data-limit={limit} data-naslov={naslov}></span>
<script>
import { fetchCurrentUser, fetchDashboardData, fetchServiserTerminalData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
import { initFilters } from "../scripts/filters.js";
async function renderirajRadneNalogeKlijentski() {
const container = document.getElementById('nalozi-list');
const sekcija = document.getElementById('radni-nalozi-sekcija');
const bridge = document.getElementById('rn-bridge');
if (!container || !sekcija || !bridge) return;
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
if (!token) {
sekcija.setAttribute('data-state', 'error');
container.innerHTML = `
<div class="p-20 text-center flex flex-col items-center justify-center gap-3">
<div class="w-12 h-12 bg-red-600/10 text-red-600 rounded-xl flex items-center justify-center text-lg mb-2">
<i class="fa-solid fa-shield-halved"></i>
</div>
<h4 class="text-xs font-black uppercase tracking-widest text-red-600">Pristup odbijen</h4>
<p class="text-[10px] text-gray-400 font-bold uppercase tracking-wider italic">Niste autorizirani za pregled operativnih naloga flote.</p>
</div>
`;
return;
}
const limit = parseInt(bridge.getAttribute('data-limit') || '0');
const originalNaslov = bridge.getAttribute('data-naslov') || 'Zadnje aktivnosti';
const urlParams = new URLSearchParams(window.location.search);
const statusFilter = urlParams.get('status')?.toLowerCase();
try {
sekcija.setAttribute('data-state', 'loading');
const user = await fetchCurrentUser();
const ulogaGore = user?.uloga ? String(user.uloga).toUpperCase().trim() : '';
let nalozi = [];
if (ulogaGore === 'SERVISER' && user?.id) {
const terminalData = await fetchServiserTerminalData(user.id);
nalozi = terminalData?.radni_nalozi || [];
} else {
const data = await fetchDashboardData();
nalozi = data?.nalozi || [];
}
// 3. Proračun statistike na klijentskoj strani
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
const zavrsenoCount = nalozi.filter(n => ['zavrsen', 'završeno', 'gotovo', 'zavrseno'].includes(n.status?.toLowerCase())).length;
// 4. 🚀 USKLAĐENO HVAĆANJE I FILTRIRANJE PREMA TVOM data-filter PROPSU:
const card1 = sekcija.querySelector('[data-filter="planirano"]');
if (card1) {
const span = card1.querySelector('span');
if (span) span.textContent = `Planirano: ${planiranoCount}`;
if (planiranoCount > 0) card1.classList.remove('hidden'); else card1.classList.add('hidden');
}
const card2 = sekcija.querySelector('[data-filter="u_radu"]');
if (card2) {
const span = card2.querySelector('span');
if (span) span.textContent = `U radu: ${uRaduCount}`;
if (uRaduCount > 0) card2.classList.remove('hidden'); else card2.classList.add('hidden');
}
const card3 = sekcija.querySelector('[data-filter="zavrseno"]');
if (card3) {
const span = card3.querySelector('span');
if (span) span.textContent = `Završeno: ${zavrsenoCount}`;
if (zavrsenoCount > 0) card3.classList.remove('hidden'); else card3.classList.add('hidden');
}
// 5. Filtriranje i limitiranje niza prema klijentskom stanju URL-a
let filtriraniNalozi = statusFilter ? nalozi.filter(n => n.status?.toLowerCase() === statusFilter) : nalozi;
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
const glavniNaslov = sekcija.querySelector('h2');
if (glavniNaslov) {
glavniNaslov.innerText = statusFilter ? `Filtrirano: ${formatStatus(statusFilter)}` : originalNaslov;
}
sekcija.setAttribute('data-state', 'loaded');
// 7. Generiranje HTML kartica radnih naloga unutar tijela
if (prikazaniNalozi.length === 0) {
container.innerHTML = `<div class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest">Nema zapisa za odabrani status</div>`;
return;
}
container.innerHTML = prikazaniNalozi.map((n) => {
const statusMalo = n.status?.toLowerCase();
const statusKlasa = getStatusColorClass(statusMalo);
const ikona = statusMalo === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice';
const klijentPrikaz = n.kupac_naziv || n.klijent_naziv || 'Nepoznat klijent';
const strojPrikaz = n.stroj_naziv || 'Bez stroja';
const voziloPrikaz = n.vozilo_naziv || 'Bez vozila';
return `
<a href="/operativa/radni-nalozi/${n.id}" class="flex flex-wrap items-center justify-between p-6 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700/50 last:border-none hover:bg-gray-50/50 dark:hover:bg-gray-900/20 transition-all gap-4 group">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-2xl flex items-center justify-center text-lg shadow-sm group-hover:scale-105 transition-transform">
<i class="fa-solid ${ikona}"></i>
</div>
<div>
<h4 class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">#${n.broj_naloga}</h4>
<p class="text-[11px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">${formatStatus(statusMalo)}</p>
<p class="text-[10px] text-gray-400/70 font-medium mt-1">
${klijentPrikaz} | ${voziloPrikaz} | ${strojPrikaz}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-black px-3 py-1.5 rounded-full border uppercase tracking-wider ${statusKlasa}">
${formatStatus(statusMalo)}
</span>
<div class="w-9 h-9 flex items-center justify-center bg-gray-50 dark:bg-gray-700/50 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 rounded-xl border border-gray-100 dark:border-gray-600 shadow-sm transition-all">
<i class="fa-solid fa-chevron-right text-xs"></i>
</div>
</div>
</a>
`;
}).join('');
initFilters();
} catch (err) {
console.error("Kritična greška u klijentskom renderu radnih naloga:", err);
sekcija.setAttribute('data-state', 'error');
container.innerHTML = '<div class="p-10 text-center text-red-500 font-bold uppercase text-xs">Greška pri sinkronizaciji operativnih podataka.</div>';
}
}
renderirajRadneNalogeKlijentski();
document.addEventListener('astro:after-swap', renderirajRadneNalogeKlijentski);
</script>

View File

@@ -1,6 +1,7 @@
---
// src/components/Navbar.astro
import site from "../data/site.json";
import Button from "./Button.astro";
const currentPath = Astro.url.pathname;
---
@@ -9,7 +10,6 @@ const currentPath = Astro.url.pathname;
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-20 items-center">
<!-- LOGO SEKCIJA -->
<div class="flex items-center">
<a href="/" class="flex items-center gap-3 group">
<div class="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:rotate-6 transition-transform duration-300">
@@ -21,87 +21,159 @@ const currentPath = Astro.url.pathname;
</a>
</div>
<div id="nav-auth-section" class="hidden flex items-center gap-4 group/auth" data-state="loading">
<div class="hidden group-data-[state=anonymous]/auth:block">
<Button
id="nav-login-btn"
href="/login"
variant="none"
class="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-2xl text-xs font-black uppercase tracking-wider shadow-lg shadow-blue-500/20 active:scale-95 transition-all border border-blue-500 cursor-pointer italic"
>
<i class="fa-solid fa-right-to-bracket"></i> Prijava
</Button>
</div>
<div class="hidden group-data-[state=authenticated]/auth:block">
<Button
id="mobile-menu-button"
variant="none"
class="w-12 h-12 flex items-center justify-center rounded-2xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 active:scale-90 transition-all border border-gray-100 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50"
>
<i class="fa-solid fa-bars-staggered text-xl"></i>
</Button>
</div>
<!-- MOBILE MENU GUMB -->
<div class="md:hidden flex items-center">
<button
id="mobile-menu-button"
type="button"
class="w-12 h-12 flex items-center justify-center rounded-2xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 active:scale-90 transition-all border border-gray-100 dark:border-gray-700"
>
<i class="fa-solid fa-bars-staggered text-xl"></i>
</button>
</div>
</div>
</div>
<!-- LEBDEĆI MOBILNI IZBORNIK (Transparentan) -->
<div
id="mobile-menu"
class="hidden absolute top-[calc(100%+0.5rem)] left-4 right-4
class="hidden absolute top-[calc(100%+0.5rem)] md:left-auto left-4 right-4 md:right-8 md:w-80
bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl
border border-white/20 dark:border-gray-800/50
p-4 rounded-[2.5rem] shadow-2xl z-[100]
p-4 rounded-[2.5rem] shadow-2xl shadow-blue-500/5 z-[100]
transition-all animate-in fade-in slide-in-from-top-4"
>
<div class="flex flex-col gap-2">
{site.navigation.map((item) => (
<a
href={item.url}
class="flex items-center justify-between px-6 py-4 rounded-[1.8rem] font-black text-[11px] uppercase tracking-widest text-gray-600 dark:text-gray-300 hover:bg-blue-600 hover:text-white transition-all duration-300 group"
{site.navigation.map((item) => {
// Provjera je li trenutna putanja aktivna (uzima u obzir i potencijalne dinamičke ID pod-rute)
const isCurrent = currentPath === item.url || (item.url === '/operativa/serviseri' && currentPath.includes('/operativa/serviseri'));
return (
<a
href={item.url}
data-nav-link={item.url}
class={`flex items-center justify-between px-6 py-4 rounded-[1.8rem] font-black text-[11px] uppercase tracking-widest transition-all duration-300 group italic ${
isCurrent
? "bg-blue-600 text-white shadow-lg shadow-blue-500/20"
: "text-gray-600 dark:text-gray-300 hover:bg-blue-600 hover:text-white"
}`}
>
{item.name}
<i class="fa-solid fa-chevron-right text-[10px] opacity-30 group-hover:translate-x-1 group-hover:opacity-100 transition-all"></i>
</a>
);
})}
<div class="w-full mt-1 mb-2">
<Button
href="/operativa/radni-nalozi/novi"
variant="none"
class="w-full bg-blue-600 text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center shadow-lg shadow-blue-500/40 active:scale-95 transition-all block italic"
>
{item.name}
<i class="fa-solid fa-chevron-right text-[10px] opacity-30 group-hover:translate-x-1 group-hover:opacity-100 transition-all"></i>
</a>
))}
<i class="fa-solid fa-plus mr-2"></i> Novi nalog
</Button>
</div>
<hr class="my-2 border-gray-100/50 dark:border-gray-800/50" />
<a href="/operativa/radni-nalozi/novi" class="w-full bg-blue-600 text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center shadow-lg shadow-blue-500/40 active:scale-95 transition-all">
<i class="fa-solid fa-plus mr-2"></i> Novi nalog
</a>
<div class="w-full">
<Button
id="logout-mobile"
variant="none"
class="w-full bg-red-600/10 text-red-600 hover:bg-red-600 hover:text-white p-5 rounded-[1.8rem] font-black uppercase text-[11px] tracking-[0.2em] text-center active:scale-95 transition-all border border-red-100/30 dark:border-red-900/30 cursor-pointer block italic"
>
<i class="fa-solid fa-right-from-bracket mr-2"></i> Odjava iz sustava
</Button>
</div>
</div>
</div>
</nav>
<script>
function setupNavbar() {
import { logout, fetchCurrentUser } from "../lib/api";
async function setupNavbar() {
const btn = document.getElementById('mobile-menu-button');
const menu = document.getElementById('mobile-menu');
const authSection = document.getElementById('nav-auth-section');
const logoutMobile = document.getElementById('logout-mobile');
if (btn && menu) {
// Re-inicijalizacija gumba za Astro View Transitions
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
btn.parentNode?.replaceChild(newBtn, btn);
if (!authSection) return;
newBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
const stanjeAutentifikacije = token ? 'authenticated' : 'anonymous';
// Zatvori menu na klik izvan njega
document.addEventListener('click', (e) => {
if (!menu.contains(e.target as Node) && !newBtn.contains(e.target as Node)) {
menu.classList.add('hidden');
// 1. Postavljamo stanje u DOM (odmah prebacuje vidljivost gumbova Prijava / Menu)
authSection.setAttribute('data-state', stanjeAutentifikacije);
authSection.classList.remove('hidden');
authSection.classList.add('flex');
// 2. Dinamičko popravljanje URL putanje za servisera ako imamo token i menu elemente
if (token && menu) {
let userProfil = null;
try {
// 🚀 OBRANA OD KRAHA: Omotavamo hvat u zaseban try-catch kako greška s API-ja ne bi srušila cijeli navbar
userProfil = await fetchCurrentUser();
} catch (apiErr) {
console.warn("Navbar nije uspio dohvatiti trenutnog korisnika s API-ja:", apiErr);
}
if (userProfil && userProfil.id) {
const serviserLink = menu.querySelector('[data-nav-link="/operativa/serviseri"]') as HTMLAnchorElement;
if (serviserLink) {
serviserLink.href = `/operativa/serviseri/${userProfil.id}`;
// Popravak aktivne klase ukoliko smo točno na toj dinamičkoj ruti
if (window.location.pathname === `/operativa/serviseri/${userProfil.id}`) {
serviserLink.classList.add('bg-blue-600', 'text-white', 'shadow-lg', 'shadow-blue-500/20');
serviserLink.classList.remove('text-gray-600', 'dark:text-gray-300');
}
}
});
// Zatvori nakon odabira linka
menu.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => menu.classList.add('hidden'));
});
}
}
if (!btn || !menu) return;
// Resetiranje event listenera kloniranjem (sprječava duplanje na View Transitions okidanju)
const newBtn = btn.cloneNode(true) as HTMLButtonElement;
btn.parentNode?.replaceChild(newBtn, btn);
newBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!menu.contains(e.target as Node) && !newBtn.contains(e.target as Node)) {
menu.classList.add('hidden');
}
});
menu.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => menu.classList.add('hidden'));
});
logoutMobile?.addEventListener('click', () => {
logout();
});
}
// Logout funkcija
const logout = () => {
sessionStorage.removeItem('user_session');
window.location.href = '/login';
};
document.getElementById('logout-btn')?.addEventListener('click', logout);
document.getElementById('logout-mobile')?.addEventListener('click', logout);
// Okidanje na inicijalno učitavanje i Astro after-swap
setupNavbar();
document.addEventListener('astro:after-swap', setupNavbar);
</script>

View File

@@ -0,0 +1,161 @@
---
// src/components/PutniNalogLista.astro
import NaslovList from "./NaslovList.astro";
interface Props {
naslov?: string;
prikaziNaslov?: boolean;
voziloId?: string | number;
}
const {
naslov = "Putni nalozi i rute",
prikaziNaslov = true,
voziloId = null,
} = Astro.props;
---
<div class="space-y-6" id="putni-nalozi-sekcija">
{prikaziNaslov && (
<NaslovList
naslov={naslov}
ukupno={0}
label1="Aktivne rute" count1={0} filter1="aktivan"
label2="Završeni" count2={0} filter2="zavrsen"
/>
)}
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
<div id="pn-loader" class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse">
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija putnih naloga...
</div>
<div id="putni-nalozi-list" class="space-y-0"></div>
</div>
</div>
<span id="pn-bridge" data-vozilo-id={voziloId} data-naslov={naslov}></span>
<script>
import { fetchPutniNaloziData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
import { initFilters } from "../scripts/filters.js";
async function renderirajPutneNalogeKlijentski() {
const bridge = document.getElementById('pn-bridge');
const container = document.getElementById('putni-nalozi-list');
const loader = document.getElementById('pn-loader');
if (!bridge || !container) return;
const voziloId = bridge.getAttribute('data-vozilo-id');
const originalNaslov = bridge.getAttribute('data-naslov') || 'Putni nalozi i rute';
// Dohvat aktivnog filtera iz URL-a na klijentu (?status=aktivan)
const urlParams = new URLSearchParams(window.location.search);
const statusFilter = urlParams.get('status')?.toLowerCase();
try {
// 1. Dohvat s API-ja (Sada sigurno čita localStorage token u pregledniku)
const data = await fetchPutniNaloziData();
// Prilagodba ako API vraća omotan objekt ili čisti niz
const sviPutniNalozi = Array.isArray(data) ? data : (data?.results || []);
// 2. STROGO FILTRIRANJE PREMA ID-JU VOZILA (Na razini podataka)
let filtriraniNalozi = sviPutniNalozi;
if (voziloId) {
const ciljaniId = parseInt(voziloId);
filtriraniNalozi = sviPutniNalozi.filter(pn => {
const vId = pn.vozilo?.id || pn.vozilo || pn.vozilo_id;
return parseInt(vId) === ciljaniId;
});
}
// 3. Izračun statistike na klijentskoj strani (isključivo za ovo vozilo ako je ID prisutan)
const aktivniCount = filtriraniNalozi.filter(pn => pn.status?.toLowerCase() === 'aktivan').length;
const zavrseniCount = filtriraniNalozi.filter(pn => ['zavrsen', 'završeno'].includes(pn.status?.toLowerCase())).length;
// Opcionalno dodatno filtriranje po statusu s gornjih gumbića
if (statusFilter) {
filtriraniNalozi = filtriraniNalozi.filter(pn => pn.status?.toLowerCase() === statusFilter);
}
// 4. Dinamičko ažuriranje brojača i naslova u DOM-u (NaslovList)
const sekcija = document.getElementById('putni-nalozi-sekcija');
if (sekcija) {
const glavniNaslov = sekcija.querySelector('h2');
if (glavniNaslov) {
glavniNaslov.innerText = statusFilter ? `Filtrirano: ${formatStatus(statusFilter)}` : originalNaslov;
}
const ukupnoBadge = sekcija.querySelector('h2 + span, .ukupno-badge');
if (ukupnoBadge) {
ukupnoBadge.textContent = filtriraniNalozi.length.toString();
}
const aktivniBadge = sekcija.querySelector('[data-filter="aktivan"] span, [filter-type="aktivan"]');
if (aktivniBadge) aktivniBadge.textContent = aktivniCount.toString();
const zavrseniBadge = sekcija.querySelector('[data-filter="zavrsen"] span, [filter-type="zavrsen"]');
if (zavrseniBadge) zavrseniBadge.textContent = zavrseniCount.toString();
}
// Sakrij loader
if (loader) loader.classList.add('hidden');
// 5. Generiranje HTML-a (Preslikana točna struktura tvog GenericKarticaItem)
if (filtriraniNalozi.length === 0) {
container.innerHTML = `
<div class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest">
Nema evidentiranih putnih naloga
</div>`;
return;
}
container.innerHTML = filtriraniNalozi.map((pn) => {
const statusMalo = pn.status?.toLowerCase();
const statusKlasa = getStatusColorClass(statusMalo);
const relacija = pn.relacija || pn.mjesto_odredista || 'Terenska lokacija';
const vozilo = pn.vozilo_naziv || 'Dodijeljeno vozilo';
const izvrsitelj = pn.korisnik_ime || 'Vozač tehničar';
const pocetniKm = pn.pocetna_km?.toLocaleString('hr-HR') || 0;
return `
<a href="/operativa/putni-nalozi/${pn.id}" class="flex flex-wrap items-center justify-between p-6 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700/50 last:border-none hover:bg-gray-50/50 dark:hover:bg-gray-900/20 transition-all gap-4 group">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-2xl flex items-center justify-center text-lg shadow-sm group-hover:scale-105 transition-transform">
<i class="fa-solid fa-route"></i>
</div>
<div>
<h4 class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">#${pn.broj_naloga}</h4>
<p class="text-[11px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">${relacija}</p>
<p class="text-[10px] text-gray-400/70 font-medium mt-1">
Vozilo: ${vozilo} | Izvršitelj: ${izvrsitelj} | Početno: ${pocetniKm} KM
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-black px-3 py-1.5 rounded-full border uppercase tracking-wider ${statusKlasa}">
${pn.status_prikaz || formatStatus(statusMalo)}
</span>
<div class="w-9 h-9 flex items-center justify-center bg-gray-50 dark:bg-gray-700/50 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 rounded-xl border border-gray-100 dark:border-gray-600 shadow-sm transition-all">
<i class="fa-solid fa-chevron-right text-xs"></i>
</div>
</div>
</div>
`;
}).join('');
// Pokretanje klijentskih filtera nakon što se DOM uspješno izgradi
initFilters();
} catch (err) {
console.error("Kritična greška u klijentskom renderu putnih naloga:", err);
if (container) container.innerHTML = '<div class="p-10 text-center text-red-500 font-bold uppercase text-xs">Greška pri sinkronizaciji putnih naloga.</div>';
}
}
// Izvršavanje pri učitavanju i re-inicijalizacija kod Astro View Transitions prebacivanja rute
renderirajPutneNalogeKlijentski();
document.addEventListener('astro:after-swap', renderirajPutneNalogeKlijentski);
</script>

View File

@@ -1,9 +1,6 @@
---
// src/components/RadniNalogLista.astro
import { fetchDashboardData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
import NaslovList from "./NaslovList.astro";
import GenericKarticaItem from "./GenericKarticaItem.astro";
interface Props {
limit?: number;
@@ -16,80 +13,190 @@ const {
naslov = "Zadnje aktivnosti",
prikaziNaslov = true,
} = Astro.props;
// 1. Dohvat filtera iz URL-a (pretvaramo u mala slova radi sigurnosti)
const statusFilter = Astro.url.searchParams.get('status')?.toLowerCase();
// 2. Dohvat podataka s API-ja
const { nalozi = [] } = await fetchDashboardData();
// 3. Logika filtriranja (Serverska strana - tolerantna na velika/mala slova)
let filtriraniNalozi = nalozi;
if (statusFilter) {
filtriraniNalozi = nalozi.filter(n => n.status?.toLowerCase() === statusFilter);
}
// 4. POPRAVLJENO: Izračun brojeva uz obvezno pretvaranje u mala slova (.toLowerCase())
// Na ovaj način 'PLANIRANO' s backenda sigurno postaje 'planirano' u JS-u
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
// 5. Primjena limita na prikaz (npr. top 5 nalozi)
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
// Dinamički naslov ovisno o filteru
const prikazaniNaslov = statusFilter
? `Filtrirano: ${formatStatus(statusFilter)}`
: naslov;
---
<div class="space-y-6">
<div class="space-y-6 group" id="radni-nalozi-sekcija" data-state="loading">
{prikaziNaslov && (
<NaslovList
naslov={prikazaniNaslov}
ukupno={filtriraniNalozi.length}
label1="Planirano"
count1={planiranoCount} // Sada će ispravno prikazati stvarni broj
filter1="planirano"
label2="U radu"
count2={uRaduCount} // Sada će ispravno prikazati stvarni broj
filter2="u_radu"
naslov={naslov}
sinkronizirano={false} label1="Planirano" count1={0} filter1="planirano"
label2="U radu" count2={0} filter2="u_radu"
/>
)}
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 overflow-hidden">
<div id="nalozi-list">
{prikazaniNalozi.length > 0 ? (
prikazaniNalozi.map((n) => (
<GenericKarticaItem
href={`/operativa/radni-nalozi/${n.id}`}
status={n.status?.toLowerCase()} // Šaljemo ujednačeni lowercase u podkomponente
statusBojaClass={getStatusColorClass(n.status?.toLowerCase())}
ikona={n.status?.toLowerCase() === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice'}
naslov={`#${n.broj_naloga}`}
subNaslov={formatStatus(n.status?.toLowerCase())}
metaTekst={`${n.klijent_naziv || 'Nepoznat klijent'} | ${n.vozilo_naziv || 'Bez vozila'} | ${n.stroj_naziv || 'Bez stroja'}`}
statusPrikaz={formatStatus(n.status?.toLowerCase())}
bojaTeme={n.boja_teme || 'blue'}
/>
))
) : (
<div class="p-20 text-center uppercase font-black opacity-20 italic">
Nema zapisa za odabrani status
</div>
)}
<div
id="nalozi-loader"
class="p-20 text-center text-xs font-black uppercase tracking-widest text-gray-400 animate-pulse group-data-[state=loaded]:hidden group-data-[state=error]:hidden"
>
<i class="fa-solid fa-circle-notch animate-spin mr-3 text-blue-600 text-base"></i> Sinkronizacija radnih naloga...
</div>
<div id="nalozi-list" class="space-y-0">
<div id="nalozi-placeholder" class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest group-data-[state=loaded]:hidden">
Nema zapisa za odabrani status
</div>
</div>
</div>
</div>
<script>
// Ponovno inicijaliziramo klijentsku skriptu za klikove na kartice filtera
import { initFilters } from "../scripts/filters.js";
// Prvo pokretanje
initFilters();
<span id="rn-bridge" data-limit={limit} data-naslov={naslov}></span>
// Podrška za Astro View Transitions
document.addEventListener('astro:after-swap', initFilters);
<script>
import { fetchDashboardData } from "../lib/api";
import { getStatusColorClass, formatStatus } from "../utils/ui";
import { initFilters } from "../scripts/filters.js";
async function renderirajRadneNalogeKlijentski() {
const container = document.getElementById('nalozi-list');
const sekcija = document.getElementById('radni-nalozi-sekcija');
const bridge = document.getElementById('rn-bridge');
if (!container || !sekcija || !bridge) return;
// 1. KLIJENTSKI OSIGURAČ: Provjera autorizacije prije API poziva preko ključa iz tvojeg api.js
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
if (!token) {
// Ako nema tokena, odmah zaključaj listu i baci sigurnosni Error izgled
sekcija.setAttribute('data-state', 'error');
container.innerHTML = `
<div class="p-20 text-center flex flex-col items-center justify-center gap-3">
<div class="w-12 h-12 bg-red-600/10 text-red-600 rounded-xl flex items-center justify-center text-lg mb-2">
<i class="fa-solid fa-shield-halved"></i>
</div>
<h4 class="text-xs font-black uppercase tracking-widest text-red-600">Pristup odbijen</h4>
<p class="text-[10px] text-gray-400 font-bold uppercase tracking-wider italic">Niste autorizirani za pregled operativnih naloga flote.</p>
</div>
`;
return;
}
const limit = parseInt(bridge.getAttribute('data-limit') || '0');
const originalNaslov = bridge.getAttribute('data-naslov') || 'Zadnje aktivnosti';
const urlParams = new URLSearchParams(window.location.search);
const statusFilter = urlParams.get('status')?.toLowerCase();
try {
// Svaki put kad klijent pokrene funkciju, osiguravamo loading stanje
sekcija.setAttribute('data-state', 'loading');
// 2. Dohvat podataka (Šalje fetch zahtjev s ispravnim Authorization zaglavljem)
const data = await fetchDashboardData();
const nalozi = data?.nalozi || [];
// 3. Proračun statistike na klijentskoj strani
const planiranoCount = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
const uRaduCount = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
const zavrsenoCount = nalozi.filter(n => ['zavrsen', 'završeno', 'gotovo', 'zavrseno'].includes(n.status?.toLowerCase())).length;
// 4. 🛠️ POPRAVLJENO: Upisivanje vrijednosti i inteligentno sakrivanje samo ako je brojač točno 0
const card1 = sekcija.querySelector('#card-filter1');
if (card1) {
const val = card1.querySelector('.count-value');
if (val) val.textContent = planiranoCount.toString();
if (planiranoCount > 0) {
card1.classList.remove('hidden');
} else {
card1.classList.add('hidden');
}
}
const card2 = sekcija.querySelector('#card-filter2');
if (card2) {
const val = card2.querySelector('.count-value');
if (val) val.textContent = uRaduCount.toString();
if (uRaduCount > 0) {
card2.classList.remove('hidden');
} else {
card2.classList.add('hidden');
}
}
const card3 = sekcija.querySelector('#card-filter3');
if (card3) {
const val = card3.querySelector('.count-value');
if (val) val.textContent = zavrsenoCount.toString();
if (zavrsenoCount > 0) {
card3.classList.remove('hidden');
} else {
card3.classList.add('hidden');
}
}
// 5. Filtriranje i limitiranje niza prema klijentskom stanju URL-a
let filtriraniNalozi = statusFilter ? nalozi.filter(n => n.status?.toLowerCase() === statusFilter) : nalozi;
const prikazaniNalozi = limit > 0 ? filtriraniNalozi.slice(0, limit) : filtriraniNalozi;
// Ažuriranje teksta glavnog naslova ovisno o aktivnom filteru
const glavniNaslov = sekcija.querySelector('h2');
if (glavniNaslov) {
glavniNaslov.innerText = statusFilter ? `Filtrirano: ${formatStatus(statusFilter)}` : originalNaslov;
}
// 6. 🚀 SINKRONIZACIJA PROPSA: Otključavamo kontejner unutar NaslovList.astro prebacivanjem u 'loaded'
const statCardsContainer = sekcija.querySelector('#stat-cards-container');
if (statCardsContainer) {
statCardsContainer.setAttribute('data-state', 'loaded');
}
// Isključivanje općeg loading stanja s krovne sekcije
sekcija.setAttribute('data-state', 'loaded');
// 7. Generiranje HTML kartica radnih naloga unutar tijela
if (prikazaniNalozi.length === 0) {
container.innerHTML = `<div class="p-20 text-center uppercase font-black opacity-20 italic text-xs tracking-widest">Nema zapisa za odabrani status</div>`;
return;
}
container.innerHTML = prikazaniNalozi.map((n) => {
const statusMalo = n.status?.toLowerCase();
const statusKlasa = getStatusColorClass(statusMalo);
const ikona = statusMalo === 'u_radu' ? 'fa-screwdriver-wrench' : 'fa-file-invoice';
return `
<a href="/operativa/radni-nalozi/${n.id}" class="flex flex-wrap items-center justify-between p-6 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700/50 last:border-none hover:bg-gray-50/50 dark:hover:bg-gray-900/20 transition-all gap-4 group">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-2xl flex items-center justify-center text-lg shadow-sm group-hover:scale-105 transition-transform">
<i class="fa-solid ${ikona}"></i>
</div>
<div>
<h4 class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">#${n.broj_naloga}</h4>
<p class="text-[11px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">${formatStatus(statusMalo)}</p>
<p class="text-[10px] text-gray-400/70 font-medium mt-1">
${n.klijent_naziv || 'Nepoznat klijent'} | ${n.vozilo_naziv || 'Bez vozila'} | ${n.stroj_naziv || 'Bez stroja'}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-black px-3 py-1.5 rounded-full border uppercase tracking-wider ${statusKlasa}">
${formatStatus(statusMalo)}
</span>
<div class="w-9 h-9 flex items-center justify-center bg-gray-50 dark:bg-gray-700/50 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 rounded-xl border border-gray-100 dark:border-gray-600 shadow-sm transition-all">
<i class="fa-solid fa-chevron-right text-xs"></i>
</div>
</div>
</div>
`;
}).join('');
// Aktiviraj klijentske klikove na kartice filtera
initFilters();
} catch (err) {
console.error("Kritična greška u klijentskom renderu radnih naloga:", err);
sekcija.setAttribute('data-state', 'error');
container.innerHTML = '<div class="p-10 text-center text-red-500 font-bold uppercase text-xs">Greška pri sinkronizaciji operativnih podataka.</div>';
}
}
// Prvo pokretanje pri učitavanju stranice
renderirajRadneNalogeKlijentski();
// Ponovno pokretanje prilikom SPA navigacije kroz aplikaciju
document.addEventListener('astro:after-swap', renderirajRadneNalogeKlijentski);
</script>

View File

@@ -0,0 +1,242 @@
---
// src/components/ServiserTerminal.astro
import Button from "./Button.astro";
import Toast from "./Toast.astro";
---
<div class="space-y-6 group" id="serviser-terminal-sekcija" data-state="loading">
<div class="group-data-[state=loading]:flex hidden items-center gap-3 text-xs font-black text-gray-400 uppercase tracking-widest animate-pulse bg-white dark:bg-gray-800 p-8 rounded-[2rem] border border-gray-100 dark:border-gray-700/50 shadow-md">
<i class="fa-solid fa-circle-notch animate-spin text-blue-600 text-base"></i> Sinkronizacija radnog zadatka s baze...
</div>
<div id="terminal-prazno-stanje" class="hidden p-20 text-center bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-[3rem] uppercase font-black opacity-30 italic text-xs tracking-widest shadow-2xl shadow-blue-500/5">
Trenutno nemate dodijeljenih aktivnih radnih naloga na terenu.
</div>
<div id="terminal-radni-prostor" class="hidden group-data-[state=loaded]:grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 p-8 shadow-xl shadow-blue-500/5">
<div class="flex justify-between items-start gap-4 border-b border-gray-100 dark:border-gray-700/50 pb-4 mb-6">
<div>
<h3 id="rn-broj" class="text-xl font-black uppercase tracking-tight text-gray-900 dark:text-white">#RN-UČITAVANJE</h3>
<p id="rn-klijent" class="text-xs font-bold text-gray-400 mt-0.5 uppercase tracking-wider">Klijent: ...</p>
</div>
<span id="rn-status-znacka" class="text-[10px] font-black px-4 py-2 rounded-full border uppercase tracking-wider bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/20 dark:text-amber-400 dark:border-amber-800/50">
U RADU
</span>
</div>
<div class="space-y-4">
<div>
<h4 class="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 mb-1 italic">Prijavljeni kvar / Pogonski stroj flote</h4>
<div id="rn-stroj-detalji" class="p-4 bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 rounded-2xl text-xs font-bold text-gray-700 dark:text-gray-300 shadow-inner">
Učitavanje podataka o stroju...
</div>
</div>
<div class="pt-4">
<Button id="btn-zatvori-rn" variant="none" class="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-2xl font-black uppercase text-xs tracking-[0.2em] shadow-lg shadow-emerald-500/10 active:scale-95 transition-all">
<i class="fa-solid fa-check-double mr-2"></i> Dovrši servis i aktiviraj stroj
</Button>
</div>
</div>
</div>
</div>
<div class="space-y-6">
<div id="putni-nalog-kartica" class="bg-white dark:bg-gray-800 rounded-[3rem] border border-gray-100 dark:border-gray-700 p-8 shadow-xl shadow-blue-500/5 flex flex-col justify-between min-h-[300px]">
<div>
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-600/10 text-blue-600 dark:text-blue-400 rounded-xl flex items-center justify-center text-md shadow-sm">
<i class="fa-solid fa-truck-ramp-box"></i>
</div>
<div>
<h4 id="pn-broj" class="text-sm font-black uppercase tracking-tight text-gray-900 dark:text-white">PN-UČITAVANJE</h4>
<p id="pn-vozilo" class="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Vozilo: ...</p>
</div>
</div>
<hr class="my-4 border-gray-100 dark:border-gray-700/50" />
<div class="space-y-2">
<label for="zavrsna-km" class="block text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 ml-2 italic">Završna KM Vozila</label>
<input
type="number"
id="zavrsna-km"
class="w-full px-4 py-3.5 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-blue-600 rounded-xl outline-none text-xs font-bold text-gray-900 dark:text-white shadow-inner"
placeholder="Unesi stanje brojača (km)..."
/>
</div>
</div>
<div class="pt-6">
<Button id="btn-zatvori-pn" variant="danger" class="w-full py-3.5 rounded-2xl text-[10px] tracking-wider italic">
<i class="fa-solid fa-flag-checkered mr-2"></i> Zaključaj putni list
</Button>
</div>
</div>
</div>
</div>
<Toast />
</div>
<script>
import { fetchRadniNalozi, patchNalog } from "../lib/api";
const API_BASE = import.meta.env.PUBLIC_API_URL;
let aktivniNalog: any = null;
async function inicijalizirajTerminalServisera() {
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
const sekcija = document.getElementById('serviser-terminal-sekcija');
const radniProstor = document.getElementById('terminal-radni-prostor');
const praznoStanje = document.getElementById('terminal-prazno-stanje');
if (!token || !sekcija || !radniProstor || !praznoStanje) return;
try {
sekcija.setAttribute('data-state', 'loading');
// 🛠️ OPTIMIZIRANO: Više ne dohvaćamo korisnika klijentski jer WelcomeHeader to rješava na serveru.
// Izravno dohvaćamo radne naloge kroz postojeće REST rute
const naloziData = await fetchRadniNalozi();
// Filtriramo naloge koji su u radu
const mojiNalozi = naloziData.filter((n: any) => n.status?.toLowerCase() === 'u_radu');
if (mojiNalozi.length === 0) {
sekcija.setAttribute('data-state', 'loaded');
radniProstor.classList.add('hidden');
praznoStanje.classList.remove('hidden');
return;
}
aktivniNalog = mojiNalozi[0];
praznoStanje.classList.add('hidden');
radniProstor.classList.remove('hidden');
// Injekcija podataka u DOM
document.getElementById('rn-broj')!.textContent = `#${aktivniNalog.broj_naloga}`;
document.getElementById('rn-klijent')!.textContent = `Klijent: ${aktivniNalog.klijent_naziv || 'Opći klijent'}`;
document.getElementById('rn-stroj-detalji')!.innerHTML = `
<div class="space-y-1">
<p><span class="opacity-50 font-black">STROJ U KVARU:</span> ${aktivniNalog.stroj_naziv || 'Nije specificirano'}</p>
<p><span class="opacity-50 font-black">DODIJELJENO VOZILO:</span> ${aktivniNalog.vozilo_naziv || 'Bez servisnog vozila'}</p>
</div>
`;
// Provjera postojanja povezanog putnog naloga
const putniKartica = document.getElementById('putni-nalog-kartica');
if (putniKartica) {
if (aktivniNalog.putni_nalog) {
putniKartica.classList.remove('hidden');
document.getElementById('pn-broj')!.textContent = `PN-${aktivniNalog.putni_nalog}`;
document.getElementById('pn-vozilo')!.textContent = `Vozilo: ${aktivniNalog.vozilo_naziv || 'Servisni kombi'}`;
} else {
putniKartica.classList.add('hidden');
}
}
// Prebacivanje u loaded stanje -> Tailwind CSS automatski pali radni prostor
sekcija.setAttribute('data-state', 'loaded');
} catch (err) {
console.error("Krah servisnog terminala:", err);
sekcija.setAttribute('data-state', 'error');
}
}
// --- AKCIJA A: ZATVARANJE PUTNOG NALOGA (PATCH na osnovnu stazu) ---
document.getElementById('btn-zatvori-pn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget as HTMLButtonElement;
const kmInput = document.getElementById('zavrsna-km') as HTMLInputElement;
const km = kmInput?.value;
if (!km || parseInt(km, 10) <= 0) {
window.showToast?.("Unesite ispravnu završnu kilometražu vozila.", "error");
return;
}
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-2"></i> Knjiženje kilometara...';
const url = `${API_BASE}${API_BASE.endsWith('/') ? '' : '/'}operativa/putni-nalozi/${aktivniNalog.putni_nalog}/`;
try {
const res = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
zavrsna_km: parseInt(km, 10),
status: 'završen'
})
});
if (res.ok) {
window.showToast?.("Putni nalog uspješno zaključen i arhiviran.", "success");
document.getElementById('putni-nalog-kartica')?.classList.add('opacity-40');
btn.classList.add('hidden');
kmInput.disabled = true;
} else {
window.showToast?.("Greška pri zatvaranju putnog lista.", "error");
}
} catch {
window.showToast?.("Mrežna greška u komunikaciji s DRF-om.", "error");
} {
btn.disabled = false;
btn.innerHTML = originalText;
}
});
// --- AKCIJA B: ZATVARANJE RADNOG NALOGA + PROMJENA STATUSI STROJA ---
document.getElementById('btn-zatvori-rn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget as HTMLButtonElement;
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-2"></i> Zatvaranje naloga i ažuriranje flote...';
// 1. Koristimo postojeću metodu patchNalog za prebacivanje radnog naloga u 'zavrsen'
const resNalog = await patchNalog(aktivniNalog.id, { status: 'zavrsen' });
if (resNalog) {
// 2. REST LANČANA REAKCIJA: Šaljemo PATCH izravno na stazu stroja i prebacujemo ga u 'aktivno'
if (aktivniNalog.stroj) {
const urlStroj = `${API_BASE}${API_BASE.endsWith('/') ? '' : '/'}fleet/strojevi/${aktivniNalog.stroj}/`;
await fetch(urlStroj, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({ status: 'aktivno' }) // Stroj je popravljen i spreman za pogon
});
}
window.showToast?.("Radni nalog dovršen. Stroj je uspješno pušten u rad!", "success");
// Re-render cijelog terminala (prebaciće u prazno stanje jer više nema naloga 'u_radu')
setTimeout(() => inicijalizirajTerminalServisera(), 1200);
} else {
window.showToast?.("Sinkronizacija s DRF poslužiteljem nije uspjela.", "error");
btn.disabled = false;
btn.innerHTML = originalText;
}
});
// Re-inicijalizacija i stabilnost kroz View Transitions SPA navigaciju
inicijalizirajTerminalServisera();
document.addEventListener('astro:after-swap', inicijalizirajTerminalServisera);
</script>

View File

@@ -54,7 +54,7 @@
"icon": "fa-address-book",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Pregled",
"welcomeHeaderTextH1dodatno": "klijenata",
"welcomeHeaderTextH1dodatno": " klijenata",
"welcomeHeaderPodnaslov": "Upravljanje bazom korisnika i partnera",
"welcomeHeaderPovratniURL": "/"
},
@@ -64,7 +64,7 @@
"icon": "fa-calendar-days",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Kalendar",
"welcomeHeaderTextH1dodatno": "događaja",
"welcomeHeaderTextH1dodatno": " događaja",
"welcomeHeaderPodnaslov": "Pregled operacija u stvarnom vremenu",
"welcomeHeaderPovratniURL": "/"
},
@@ -87,6 +87,16 @@
"welcomeHeaderTextH1dodatno": " Radni nalog",
"welcomeHeaderPodnaslov": "Otvaranje novog servisnog ili radnog naloga u sustavu",
"welcomeHeaderPovratniURL": "/operativa/radni-nalozi"
},
{
"name": "Evidencija Servisera",
"url": "/operativa/serviseri",
"icon": "fa-calendar-days",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Evidencija",
"welcomeHeaderTextH1dodatno": " servisera",
"welcomeHeaderPodnaslov": "Središnji pregled i administracija terenskih servisnih tehničara",
"welcomeHeaderPovratniURL": "/"
}
],
"api_endpoints": {

View File

@@ -4,27 +4,38 @@
const RAW_BASE = import.meta.env.PUBLIC_API_URL;
const API_BASE = RAW_BASE.endsWith('/') ? RAW_BASE : `${RAW_BASE}/`;
/**
* POMOĆNA FUNKCIJA: Dohvaća token i postavlja Headere
* POMOĆNA FUNKCIJA: Dohvaća token i postavlja Headere.
* Podržava i klijentski localStorage i serverski (SSR) proslijeđeni token.
* @param {Object|FormData} bodyData - Podaci koji se šalju u body-ju zahtjeva
* @param {string|null} ssrToken - Izborni token proslijeđen sa serverske strane (Astro SSR)
*/
function getAuthHeaders(bodyData = {}) {
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
function getAuthHeaders(bodyData = {}, ssrToken = null) {
const headers = {};
// 🚀 PAMETAN DOHVAT TOKENA: Ako je proslijeđen ssrToken (server), koristi njega.
// Ako nije, a nalazimo se u pregledniku (klijent), povuci ga iz localStorage.
let token = ssrToken;
if (!token && typeof window !== 'undefined') {
token = localStorage.getItem('access_token');
}
if (token) {
headers['Authorization'] = `Bearer ${token}`; // Usklađeno sa Simple JWT standardom
}
// Provjera je li bodyData FormData (za slike/naloge)
// Provjera je li bodyData FormData (za slike/naloge s terena)
const isFormData = bodyData instanceof FormData;
// Ako NIJE FormData, šaljemo JSON
// Ako NIJE FormData, šaljemo standardni JSON content type
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
/**
* Centralizirana obrada odgovora s Toast podrškom
*/
@@ -69,9 +80,13 @@ async function handleResponse(res) {
// --- RUTE ---
export const routes = {
// Autentifikacija (Maknute početne kose crte!)
// Autentifikacija
login: () => 'token/',
trenutniKorisnik: () => 'users/me/',
// Korisnici i operativni tehničari
serviseriSvi: () => 'users/',
serviserTerminal: (id) => `users/${id}/terminal/`,
// Radni nalozi
radniNalozi: (params = {}) => {
@@ -109,22 +124,72 @@ export const routes = {
// --- API METODE ---
/**
* Dohvat profila trenutno prijavljenog korisnika
* Dohvat profila korisnika iz opće liste /api/users/
* @param {string|null} ssrToken - Opcionalni token sa servera (Astro.cookies ili slično)
*/
export async function fetchCurrentUser() {
export async function fetchCurrentUser(ssrToken = null) {
if (typeof window === 'undefined' && !ssrToken) return null;
try {
const url = `${API_BASE}${routes.trenutniKorisnik()}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
headers: getAuthHeaders({}, ssrToken)
});
return await handleResponse(res);
const user = await handleResponse(res);
if (user && user.detail) return null;
return user;
} catch (e) {
console.error("fetchCurrentUser Failure:", e);
return null;
}
}
/**
* 🚀 NOVO (DRY): Dohvaća sve korisnike i vraća isključivo filtrirane servisere s terena
*/
export async function fetchServiseri() {
try {
const url = `${API_BASE}${routes.serviseriSvi()}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
const korisnici = await handleResponse(res);
if (!korisnici) return [];
const lista = Array.isArray(korisnici) ? korisnici : (korisnici.results || []);
// Filtriranje na temelju polja 'uloga' iz tvog CustomUser modela
return lista.filter(u => String(u.uloga).toUpperCase().trim() === 'SERVISER');
} catch (e) {
console.error("fetchServiseri Failure:", e);
return [];
}
}
/**
* 🚀 NOVO (DRY): Dohvaća ugniježđene terminal podatke za specifičnog servisera (Nalozi, Putni, Vozila)
* @param {number|string} id - ID servisera čiji se terminal učitava
*/
export async function fetchServiserTerminalData(id) {
try {
const url = `${API_BASE}${routes.serviserTerminal(id)}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
return await handleResponse(res);
} catch (e) {
console.error(`fetchServiserTerminalData za ID ${id} neuspješan:`, e);
return null;
}
}
/**
* Dohvat i filtriranje radnih naloga (za tablice i liste u operativi)
*/

View File

@@ -8,19 +8,14 @@ import StatsGrid from "../components/StatsGrid.astro";
import Kalendar from "../components/Kalendar/Kalendar.astro";
// API i Centralizirani Utils iz api.js
import { fetchDashboardData, fetchCurrentUser } from "../lib/api";
import { fetchDashboardData } from "../lib/api";
// 1. Dohvat podataka (paralelno radi brzine i performansi u homelabu)
const [data, user] = await Promise.all([
fetchDashboardData(),
fetchCurrentUser()
]);
// 1. Dohvat podataka s backenda (Samo bazični dashboard podaci koji se mogu poslužiti asinkrono)
const data = await fetchDashboardData();
const nalozi = data?.nalozi || [];
const imeKorisnika = user?.first_name || "Kolega";
// 2. POPRAVLJENO: Kalkulacija statistike tolerantna na velika/mala slova (.toLowerCase())
// Osiguravamo da se podaci iz baze (PLANIRANO, U_RADU) savršeno upare sa StatsGrid filterima
// 2. Kalkulacija statistike tolerantna na velika/mala slova (.toLowerCase())
const naloziURadu = nalozi.filter(n => n.status?.toLowerCase() === 'u_radu').length;
const planiraniNalozi = nalozi.filter(n => n.status?.toLowerCase() === 'planirano').length;
@@ -33,7 +28,7 @@ const zavrseniNalozi = nalozi.filter(n => {
<Layout title="Dashboard | ServisLog">
<div class="w-full space-y-5">
<WelcomeHeader ime={imeKorisnika} />
<WelcomeHeader naslov="Kontrolna ploča" />
<div class="grid grid-cols-3 gap-2 sm:gap-6 px-2">
<StatsGrid
@@ -84,10 +79,19 @@ const zavrseniNalozi = nalozi.filter(n => {
<script>
import { initFilters } from "../scripts/filters.js";
import { provjeriPristupStranici } from "../utils/auth.js";
// Inicijalizacija filtera za StatsGrid klikove na klijentu
initFilters();
// 🚀 POKREĆEMO SIGURNOSNI KLIJENTSKI GUARD:
// On odmah na klijentu provjerava is_serviser. Ako je ulogiran običan serviser,
// prisilno ga i trenutno preusmjerava na njegov dinamički terminal /operativa/serviseri/[id]
async function inicijalizirajStranicu() {
await provjeriPristupStranici();
initFilters();
}
// Re-inicijalizacija kod navigacije (Astro View Transitions)
document.addEventListener('astro:after-swap', initFilters);
// Pokreni odmah pri učitavanju u preglednik
inicijalizirajStranicu();
// Re-inicijalizacija kod klijentske navigacije (Astro View Transitions)
document.addEventListener('astro:after-swap', inicijalizirajStranicu);
</script>

View File

@@ -3,23 +3,31 @@
import Layout from "../layouts/Layout.astro";
import WelcomeHeader from "../components/WelcomeHeader.astro";
import Kalendar from "../components/Kalendar/Kalendar.astro";
// API i Utils
import { fetchCurrentUser } from "../lib/api";
// 1. Dohvaćamo samo korisnika za pozdrav u headeru
const user = await fetchCurrentUser();
const imeKorisnika = user?.first_name || "Kolega";
---
<Layout title="Kalendar | ServisLog">
<div class="w-full space-y-10">
<WelcomeHeader ime={imeKorisnika} />
<WelcomeHeader naslov="Kalendar događaja" />
<div class="grid grid-cols-1 gap-2 sm:gap-6 px-2">
<Kalendar />
</div>
</div>
</Layout>
</Layout>
<script>
import { provjeriPristupStranici } from "../utils/auth.js";
async function inicijalizirajStranicu() {
// 🚀 SIGURNOSNI UKLOP: Guard provjerava sesiju na klijentu i štiti kalendar od neautoriziranih upada
await provjeriPristupStranici();
}
// Pokreni odmah pri učitavanju u preglednik
inicijalizirajStranicu();
// Re-inicijalizacija kod klijentske navigacije kroz ERP (Astro View Transitions)
document.addEventListener('astro:after-swap', inicijalizirajStranicu);
</script>

View File

@@ -0,0 +1,51 @@
---
// src/pages/operativa/serviseri/[id].astro
import Layout from "../../../layouts/Layout.astro";
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
import RadniNalogLista from "../../../components/RadniNalogLista.astro";
import AkcijePanel from "../../../components/AkcijePanel.astro";
// 🚀 Hvatamo ID servisera iz same putanje URL-a
const { id } = Astro.params;
// 🎯 POPRAVAK: Uklonjen serverski fetchCurrentUser i redirect jer se token nalazi na klijentu!
---
<Layout title="Servisni Terminal | Operativa">
<div class="w-full space-y-10">
<WelcomeHeader />
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
<div class="md:col-span-7 space-y-6">
{/* Univerzalna CSR lista koja sama povlači naloge preko api.js ovisno o ulogi */}
<RadniNalogLista
limit={10}
naslov="Dodijeljeni zadaci servisera"
prikaziNaslov={true}
/>
</div>
<div class="md:col-span-3">
<AkcijePanel tip="dashboard" />
</div>
</div>
</div>
</Layout>
<script>
import { initFilters } from "../../../scripts/filters.js";
// 🎯 POPRAVAK: Potpuno izbačen provjeriPristupStranici.
// Ako korisnik nema pravo gledati ovaj profil, Django backend će presresti zahtjev unutar RadniNalogLista!
const pokreniSveKlijentskeSkripte = () => {
initFilters();
};
pokreniSveKlijentskeSkripte();
document.addEventListener('astro:after-swap', pokreniSveKlijentskeSkripte);
</script>

View File

@@ -0,0 +1,136 @@
---
// src/pages/operativa/serviseri/index.astro
import Layout from "../../../layouts/Layout.astro";
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
import RadniNalogLista from "../../../components/RadniNalogLista.astro";
---
<Layout title="Evidencija Servisera | Operativa">
<div class="w-full space-y-10">
<WelcomeHeader />
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
<div id="direktorij-sekcija" class="hidden md:col-span-10 space-y-6">
<div class="bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-[2.5rem] p-6 sm:p-8 shadow-xl shadow-gray-100/40 dark:shadow-none">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 pb-4 border-b border-gray-50 dark:border-gray-800">
<div>
<h2 class="text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight leading-none italic">
Registrirani <span class="text-blue-600">Serviseri</span>
</h2>
<p class="text-xs text-gray-400 mt-2 italic font-medium">Popis tehničara s aktivnim ovlastima za radne naloge</p>
</div>
<div>
<div class="bg-blue-50 dark:bg-blue-950/40 text-blue-600 dark:text-blue-400 border border-blue-100/30 dark:border-blue-900/30 italic font-black uppercase tracking-widest text-[11px] px-4 py-2 rounded-2xl w-max">
Ukupno: <span id="serviseri-counter">0</span>
</div>
</div>
</div>
<div id="serviseri-loading" class="py-12 flex flex-col items-center justify-center gap-3 text-gray-400 dark:text-gray-500 italic text-sm">
<i class="fa-solid fa-circle-notch animate-spin text-blue-600 text-2xl"></i>
Dohvaćanje operativnih podataka s poslužitelja...
</div>
<div id="serviseri-grid" class="hidden grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
</div>
<div id="serviseri-empty" class="hidden py-12 text-center text-gray-400 dark:text-gray-500 italic text-sm">
<i class="fa-solid fa-user-slash text-2xl mb-2 text-gray-300 dark:text-gray-700 block"></i>
Nema registriranih servisera u bazi podataka flote.
</div>
</div>
</div>
<div id="moji-nalozi-sekcija" class="hidden md:col-span-10 space-y-6">
<RadniNalogLista
limit={10}
prikaziNaslov={true}
/>
</div>
</div>
</div>
</Layout>
<script>
import { fetchCurrentUser, fetchServiseri } from "../../../lib/api";
async function ucitajStranicuServisera() {
const direktorijSekcija = document.getElementById('direktorij-sekcija');
const loadingEl = document.getElementById('serviseri-loading');
const gridEl = document.getElementById('serviseri-grid');
const emptyEl = document.getElementById('serviseri-empty');
const counterEl = document.getElementById('serviseri-counter');
const mojiNaloziSekcija = document.getElementById('moji-nalozi-sekcija');
try {
const user = await fetchCurrentUser();
const ulogaGore = user?.uloga ? String(user.uloga).toUpperCase().trim() : '';
// 1. Ako je običan serviser, prikaži sekciju s univerzalnom listom
if (ulogaGore === 'SERVISER') {
if (direktorijSekcija) direktorijSekcija.classList.add('hidden');
if (mojiNaloziSekcija) mojiNaloziSekcija.classList.remove('hidden');
return;
}
// 2. Ako je Admin / Voditelj, prikaži imenik tehničara
if (direktorijSekcija) direktorijSekcija.classList.remove('hidden');
if (mojiNaloziSekcija) mojiNaloziSekcija.classList.add('hidden');
const serviseri = await fetchServiseri();
if (loadingEl) loadingEl.classList.add('hidden');
if (!serviseri || serviseri.length === 0) {
if (emptyEl) emptyEl.classList.remove('hidden');
return;
}
if (counterEl) counterEl.textContent = serviseri.length.toString();
if (gridEl) {
gridEl.innerHTML = serviseri.map((s: any) => {
const inicijali = `${s.first_name?.[0] || ''}${s.last_name?.[0] || ''}`.toUpperCase() || 'ST';
const imePrezime = `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email;
return `
<div class="bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100 dark:border-gray-800/60 rounded-[1.8rem] p-5 flex items-center justify-between gap-4 hover:border-blue-500/30 dark:hover:border-blue-500/20 hover:bg-white dark:hover:bg-gray-800/60 transition-all duration-300 group">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/10 text-blue-600 dark:text-blue-400 font-black text-xs tracking-widest rounded-2xl flex items-center justify-center border border-blue-500/10 shadow-sm">
${inicijali}
</div>
<div class="text-left">
<h3 class="text-sm font-black text-gray-900 dark:text-white uppercase tracking-tight leading-none group-hover:text-blue-600 transition-colors italic">
${imePrezime}
</h3>
<p class="text-[10px] font-medium text-gray-400 mt-2 lowercase select-all">${s.email}</p>
</div>
</div>
<a href="/operativa/serviseri/${s.id}" class="w-10 h-10 flex items-center justify-center rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/80 text-gray-400 group-hover:text-white group-hover:bg-blue-600 group-hover:border-blue-500 active:scale-90 shadow-sm transition-all cursor-pointer">
<i class="fa-solid fa-chevron-right text-xs group-hover:translate-x-0.5 transition-transform"></i>
</a>
</div>
`;
}).join('');
gridEl.classList.remove('hidden');
gridEl.classList.add('grid');
}
} catch (err) {
console.error("Greška pri učitavanju stranice:", err);
if (loadingEl) loadingEl.classList.add('hidden');
// 🚀 TOAST OKIDAMO SAMO OVDJE NA GLAVNOJ STRANICI ZA IMENIK
// @ts-ignore
window.showToast?.("Sinkronizacija imenika flote neuspješna.", "error");
}
}
ucitajStranicuServisera();
document.addEventListener('astro:after-swap', ucitajStranicuServisera);
</script>

View File

@@ -0,0 +1,61 @@
// src/utils/auth.js
import { fetchCurrentUser } from "../lib/api";
/**
* Pomoćna funkcija za dohvat sigurne rute (Single Source of Truth)
*/
export function dohvatiRutuPoUlozi(uloga, userId = '') {
const ulogaGore = uloga ? String(uloga).toUpperCase().trim() : '';
if (ulogaGore === 'SERVISER') {
return userId ? `/operativa/serviseri/${userId}` : '/operativa/serviseri/';
}
return '/';
}
/**
* Jednostavan klijentski guard koji samo provjerava valjanost sesije i ovlasti
* @param {string[]} dozvoljeneUloge - Niz uloga koje smiju vidjeti stranicu
*/
export async function provjeriPristupStranici(dozvoljeneUloge = []) {
if (typeof window === 'undefined') return;
const token = localStorage.getItem('access_token');
// 1. Ako uopće nema tokena, vrati ga na prijavu
if (!token) {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return;
}
try {
// 2. Dohvaćamo profil trenutnog korisnika s backenda
const user = await fetchCurrentUser();
// 3. Ako je token nevažeći/istekao, počisti local pohranu i izbaci ga van
if (!user || !user.uloga) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
return;
}
const korisnikUlogaGore = String(user.uloga).toUpperCase().trim();
// 4. Provjera eksplicitnih ovlasti (Samo ako ih je stranica zatražila)
if (dozvoljeneUloge.length > 0) {
const dozvoljeneUlogeGore = dozvoljeneUloge.map(u => String(u).toUpperCase().trim());
// Ako korisnik nema ulogu koja je navedena u dozvoljenima za tu stranicu
if (!dozvoljeneUlogeGore.includes(korisnikUlogaGore)) {
// Saznaj kamo pripada s obzirom na ulogu i preusmjeri ga
window.location.href = dohvatiRutuPoUlozi(korisnikUlogaGore, user.id);
return;
}
}
} catch (err) {
console.error("Kritičan krah u provjeri pristupa:", err);
}
}