prije gemini provjere

This commit is contained in:
mariomitte
2026-05-31 19:21:25 +02:00
commit 9aff9c5dc1
191 changed files with 17016 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
node_modules
.astro

View File

@@ -0,0 +1 @@
PUBLIC_API_URL=https://v003-backend.captain.mitteworkspace.cloud/api

24
001.FRONTEND/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

4
001.FRONTEND/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
001.FRONTEND/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

19
001.FRONTEND/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# 001.FRONTEND/Dockerfile
FROM node:22-alpine
WORKDIR /app
# Kopiramo package.json i package-lock.json
COPY package*.json ./
# Instaliramo sve ovisnosti (uključujući devDependencies potrebne za Vite/Astro)
RUN npm install
# Kopiramo ostatak izvornog koda
COPY . .
# Otvaramo port za Astro dev server
EXPOSE 4321
# Pokrećemo dev server direktno, dopuštajući docker-composeu da upravlja volumenima
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

46
001.FRONTEND/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Astro Starter Kit: Basics
```sh
npm create astro@latest -- --template basics
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
│ └── favicon.svg
├── src
│   ├── assets
│   │   └── astro.svg
│   ├── components
│   │   └── Welcome.astro
│   ├── layouts
│   │   └── Layout.astro
│   └── pages
│   └── index.astro
└── package.json
```
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View File

@@ -0,0 +1,56 @@
// @ts-check
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import tailwindcss from '@tailwindcss/vite';
import preact from '@astrojs/preact';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
server: {
host: '0.0.0.0',
port: 4321,
},
image: {
remotePatterns: [
{
protocol: 'https',
hostname: 'frontend-operativa.local.mitteworkspace.cloud',
port: '',
pathname: '/**',
},
],
},
build: {
inlineStylesheets: 'always'
},
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', 'flowbite', 'flowbite-react']
},
server: {
watch: {
ignored: ['**/node_modules/**', '**/dist/**'],
},
allowedHosts: ['.mitteworkspace.cloud']
}
},
integrations: [preact({ compat: true })]
});

7263
001.FRONTEND/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
001.FRONTEND/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "poslovanje",
"type": "module",
"version": "0.0.1",
"overrides": {
"vite": "^7.0.0"
},
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev --host",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"start": "NODE_OPTIONS=--max-old-space-size=4096 npm run dev"
},
"dependencies": {
"@astrojs/node": "^10.0.6",
"@astrojs/preact": "^5.1.3",
"@nanostores/preact": "^1.1.0",
"@tailwindcss/vite": "^4.2.4",
"astro": "^6.3.7",
"flowbite": "^4.0.1",
"flowbite-react": "^0.12.17",
"nanostores": "^1.3.0",
"photoswipe": "^5.4.4",
"preact": "^10.29.2",
"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

@@ -0,0 +1,52 @@
import { defineAction } from 'astro:actions';
// 1. Uvozimo centraliziranu bazu, rute i pomoćnika za headere iz tvoje api.js datoteke
import { API_BASE, routes, getAuthHeaders } from '../lib/api.js';
export const operativaActions = {
kreirajRadniNalog: defineAction({
accept: 'form',
handler: async (formData, context) => {
try {
// 2. Dinamički sklapamo URL: npr. "http://localhost:8000/api/operativa/radni-nalozi/"
// Ako sutra promijeniš PUBLIC_API_URL u .env, ovdje se automatski mijenja!
const url = `${API_BASE}${routes.radniNalozi()}`;
// 3. Koristimo tvoju funkciju getAuthHeaders.
// Pošto joj prosljeđujemo formData, ona NEĆE postaviti 'Content-Type': 'application/json',
// što je ključno da browser sam generira multipart/form-data BOUNDARY!
const serverskiToken = context.cookies.get('access_token')?.value; // ili auth_token ovisno o tvom cookie-ju
const headers = getAuthHeaders(formData, serverskiToken);
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: headers
});
const resData = await response.json();
if (response.ok && resData.success) {
return {
success: true,
data: resData.data,
errors: null
};
}
return {
success: false,
data: null,
errors: resData.errors || 'Došlo je do pogreške na poslužitelju.'
};
} catch (error) {
console.error('[Action Error] Greška pri kreiranju radnog naloga:', error);
return {
success: false,
data: null,
errors: 'Mrežna pogreška ili nedostupan backend.'
};
}
}
})
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,199 @@
---
// src/components/AkcijePanel.astro
import Button from './Button.astro';
interface Props {
tip: 'dashboard' | 'radni-nalog' | 'stroj' | 'vlasnik' | 'vozilo'; // DODANO: vozilo
podaci: any;
}
const { tip, podaci } = Astro.props;
// Pomoćna funkcija za formatiranje datuma
const formatDate = (date: string) =>
new Date(date).toLocaleDateString('hr-HR', { day: '2-digit', month: '2-digit', year: 'numeric' });
---
<aside class="space-y-6">
<div class="grid gap-3">
{tip === 'dashboard' && (
<>
<div class="space-y-6">
<h2 class="text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight italic px-4">
Upravljanje
</h2>
<div class="flex flex-col gap-4">
<a href="/operativa/radni-nalozi/novi" class="group bg-blue-600 text-white p-6 rounded-[2rem] flex items-center gap-4 hover:bg-blue-700 transition-all shadow-xl shadow-blue-500/20 active:scale-95">
<div class="bg-white text-blue-600 w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg group-hover:rotate-12 transition-transform shrink-0">
<i class="fa-solid fa-plus text-xl"></i>
</div>
<div class="flex flex-col text-left">
<span class="font-black uppercase text-xs tracking-widest leading-none">Novi nalog</span>
<span class="text-[10px] opacity-70 mt-1 italic font-bold uppercase">Inicijaliziraj nalog</span>
</div>
</a>
<a href="/fleet/vozila" class="group bg-white dark:bg-gray-800 text-gray-900 dark:text-white p-6 rounded-[2rem] flex items-center gap-4 hover:border-blue-600 border border-gray-100 dark:border-gray-700 transition-all shadow-sm active:scale-95">
<div class="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 w-12 h-12 rounded-2xl flex items-center justify-center group-hover:rotate-12 transition-transform shrink-0">
<i class="fa-solid fa-truck-pickup text-xl"></i>
</div>
<div class="flex flex-col text-left">
<span class="font-black uppercase text-xs tracking-widest dark:text-white leading-none">Vozni park</span>
<span class="text-[10px] text-gray-400 mt-1 italic font-bold uppercase tracking-tighter">Status flote</span>
</div>
</a>
<a href="/kupci/svi" class="group bg-white dark:bg-gray-800 text-gray-900 dark:text-white p-6 rounded-[2rem] flex items-center gap-4 hover:border-indigo-600 border border-gray-100 dark:border-gray-700 transition-all shadow-sm active:scale-95">
<div class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 w-12 h-12 rounded-2xl flex items-center justify-center group-hover:rotate-12 transition-transform shrink-0">
<i class="fa-solid fa-address-book text-xl"></i>
</div>
<div class="flex flex-col text-left">
<span class="font-black uppercase text-xs tracking-widest dark:text-white leading-none">Klijenti</span>
<span class="text-[10px] text-gray-400 mt-1 italic font-bold uppercase tracking-tighter">Baza partnera</span>
</div>
</a>
<div class="mt-10 pt-6 border-t border-gray-200 dark:border-gray-700">
<Button variant="primary" class="w-full justify-center gap-2 !rounded-2xl py-4 uppercase font-black italic tracking-widest">
<i class="fa-solid fa-print"></i> Ispis naloga
</Button>
</div>
</div>
</div>
</>
)}
{tip === 'radni-nalog' && (
<>
<button class="w-full py-4 bg-blue-600 text-white rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20">
<i class="fa-solid fa-check-double mr-2"></i> Završi nalog
</button>
<button class="w-full py-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-gray-200 transition-all">
<i class="fa-solid fa-print mr-2"></i> Ispis (PDF)
</button>
</>
)}
{tip === 'stroj' && (
<>
<button class="w-full py-4 bg-green-600 text-white rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-green-700 transition-all shadow-lg shadow-green-500/20">
<i class="fa-solid fa-plus mr-2"></i> Novi Radni Nalog
</button>
<button class="w-full py-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-2xl font-black uppercase text-[11px] tracking-widest">
<i class="fa-solid fa-file-contract mr-2"></i> Atesti i Dokumenti
</button>
</>
)}
{tip === 'vlasnik' && (
<>
<div class="bg-white dark:bg-gray-800 rounded-[2.5rem] p-6 shadow-xl border border-gray-100 dark:border-gray-700 mb-6">
<label class="text-[9px] font-black uppercase text-indigo-600 block mb-4 tracking-[0.2em] italic">Upravljanje klijentom</label>
<div class="grid gap-3">
<Button class="w-full py-4 bg-indigo-600 text-white rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-500/20">
<i class="fa-solid fa-pen-to-square mr-2"></i> Uredi podatke
</Button>
<a href={`tel:${podaci.telefon}`} class="w-full py-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-2xl font-black uppercase text-[11px] tracking-widest hover:bg-gray-200 transition-all text-center no-underline">
<i class="fa-solid fa-phone mr-2"></i> Nazovi klijenta
</a>
</div>
</div>
<div class="bg-indigo-50/50 dark:bg-indigo-900/10 rounded-[2.5rem] p-6 border border-dashed border-indigo-200 dark:border-indigo-800">
<label class="text-[9px] font-black uppercase text-indigo-400 block mb-4 tracking-[0.2em] italic">Podaci o tvrtki</label>
<div class="space-y-4">
<div class="flex flex-col border-b border-indigo-100/50 dark:border-indigo-900/30 pb-2">
<span class="text-[9px] font-bold text-gray-400 uppercase">OIB</span>
<span class="text-sm font-black dark:text-gray-200 tracking-tighter">{podaci.oib}</span>
</div>
<div class="flex flex-col border-b border-indigo-100/50 dark:border-indigo-900/30 pb-2">
<span class="text-[9px] font-bold text-gray-400 uppercase">Adresa</span>
<span class="text-sm font-black dark:text-gray-200 leading-tight">{podaci.adresa}, {podaci.grad}</span>
</div>
<div class="flex flex-col">
<span class="text-[9px] font-bold text-gray-400 uppercase">Email</span>
<span class="text-sm font-black text-indigo-600 truncate">{podaci.email}</span>
</div>
</div>
</div>
</>
)}
{tip === 'vozilo' && (
<>
<div class="bg-white dark:bg-gray-800 rounded-[2.5rem] p-6 shadow-xl border border-gray-100 dark:border-gray-700 mb-2">
<label class="text-[9px] font-black uppercase text-blue-600 block mb-4 tracking-[0.2em] italic">Upravljanje vozilom</label>
<div class="grid gap-3">
<Button
id="btn-brzi-servis"
variant="primary"
class="w-full justify-center py-4 rounded-2xl font-black uppercase text-[11px] tracking-widest shadow-lg shadow-blue-500/20"
data-id={podaci.id}
>
<i class="fa-solid fa-screwdriver-wrench mr-2"></i> Otvori Servis
</Button>
<Button
href={`/operativa/radni-nalozi?vozilo=${podaci.id}`}
variant="secondary"
class="w-full justify-center py-4 rounded-2xl font-black uppercase text-[11px] tracking-widest"
>
<i class="fa-solid fa-file-invoice mr-2 opacity-70"></i> Radni Nalozi
</Button>
</div>
</div>
</>
)}
</div>
<div class="bg-gray-50 dark:bg-gray-900/40 rounded-[2.5rem] p-6 border border-dashed border-gray-200 dark:border-gray-700">
<label class="text-[9px] font-black uppercase text-gray-400 block mb-4 tracking-[0.2em] italic">Informacije sustava</label>
<div class="space-y-4">
{tip === 'radni-nalog' && (
<>
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
<span class="text-[10px] font-bold text-gray-400 uppercase">Status</span>
<span class="text-xs font-black text-red-500 uppercase italic">{podaci.status}</span>
</div>
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
<span class="text-[10px] font-bold text-gray-400 uppercase">Izradio</span>
<span class="text-xs font-black dark:text-gray-200">{podaci.izvrsitelj_ime}</span>
</div>
</>
)}
{tip === 'stroj' && (
<>
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
<span class="text-[10px] font-bold text-gray-400 uppercase">Zadnji Atest</span>
<span class="text-xs font-black text-green-500 italic">{formatDate(podaci.datum_zadnjeg_atesta)}</span>
</div>
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
<span class="text-[10px] font-bold text-gray-400 uppercase">Godina Proizv.</span>
<span class="text-xs font-black dark:text-gray-200">{podaci.godina_proizvodnje}</span>
</div>
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
<span class="text-[10px] font-bold text-gray-400 uppercase">Sustav ID</span>
<span class="text-xs font-black dark:text-gray-200">#{podaci.id}</span>
</div>
</>
)}
{tip === 'vozilo' && (
<>
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
<span class="text-[10px] font-bold text-gray-400 uppercase">Sustav ID</span>
<span class="text-xs font-black dark:text-gray-200">#{podaci.id}</span>
</div>
<div class="flex justify-between items-end border-b border-gray-100 dark:border-gray-800 pb-2">
<span class="text-[10px] font-bold text-gray-400 uppercase">DB Status</span>
<span class="text-xs font-black text-blue-500 uppercase italic">{podaci.status}</span>
</div>
</>
)}
</div>
</div>
</aside>

View File

@@ -0,0 +1,17 @@
import { useState } from 'preact/hooks';
export default function Brojac() {
const [count, setCount] = useState(0);
return (
<div className="p-4 bg-gray-100 rounded-lg text-center">
<p className="text-xl font-bold mb-2">Brojač: {count}</p>
<button
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
onClick={() => setCount(count + 1)}
>
Povećaj
</button>
</div>
);
}

View File

@@ -0,0 +1,30 @@
---
// src/components/Button.astro
interface Props {
type?: "button" | "submit";
variant?: "primary" | "danger" | "outline";
id?: string;
class?: string;
}
const { type = "button", variant = "primary", id, class: className } = Astro.props;
const variants = {
primary: "bg-blue-600 hover:bg-blue-700 text-white shadow-blue-500/20",
danger: "bg-red-600 hover:bg-red-700 text-white shadow-red-500/20",
outline: "bg-transparent border-2 border-gray-200 text-gray-500 hover:border-blue-600 hover:text-blue-600 shadow-none"
};
---
<button
type={type}
id={id}
class:list={[
"inline-flex items-center justify-center gap-4 px-10 py-6 rounded-[2rem] font-black uppercase text-xs tracking-[0.4em] transition-all active:scale-[0.98] shadow-2xl",
variants[variant],
className
]}
>
<slot name="icon" />
<slot />
</button>

View File

@@ -0,0 +1,108 @@
// src/components/auth/Login.jsx
import { h } from 'preact';
import { useFormSubmit } from '../../hooks/useFormSubmit';
import { useAuthRedirect } from '../../hooks/useAuthRedirect';
import { setAuthUser } from '../../stores/appState';
import { actions } from 'astro:actions';
import { getTrenutniKorisnik } from '../../lib/api';
import { loginKorisnika } from '../../stores/appState';
export default function LoginFOrm() {
// Izbaci korisnika ako je već prijavljen
useAuthRedirect('/');
// Povezivanje uspješnog logina s NanoStore-om
const handleUspjesnaPrijava = async (backendPodaci) => {
// 1. Dohvaćamo profil s backenda (Race condition osigurač)
const stvarniKorisnik = await getTrenutniKorisnik(backendPodaci.access);
// Priprema korisničkog objekta (iz baze ili fallback)
const korisnikZaUpis = stvarniKorisnik || backendPodaci.user || { first_name: 'Serviser', role: 'SERVISER' };
// 2. 🚀 DRY TRIJUMF: Jedna linija koda koja sprema token i budi AuthStatus gumb!
loginKorisnika(korisnikZaUpis, backendPodaci.access);
};
// Konfiguracija kuke za slanje forme
const { handleSubmit, loading } = useFormSubmit(
actions.loginKorisnika,
'/',
"Uspješna prijava u sustav!",
handleUspjesnaPrijava
);
return (
<div class="max-w-md mx-auto w-full p-10 bg-white dark:bg-gray-900 rounded-[3rem] border border-gray-100 dark:border-gray-800 shadow-2xl shadow-blue-500/10">
{/* --- ZAGLAVLJE FORME (Napredni UI) --- */}
<div class="mb-10 text-center sm:text-left">
<div class="inline-flex w-14 h-14 bg-blue-600 rounded-2xl items-center justify-center shadow-lg shadow-blue-500/20 mb-6 rotate-3 sm:-ml-2">
<i class="fa-solid fa-lock text-white text-xl"></i>
</div>
<h2 class="text-4xl font-black text-gray-900 dark:text-white uppercase tracking-tighter italic leading-none">Prijava</h2>
<p class="text-gray-500 dark:text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] mt-3 opacity-70 italic">
ServisLog <span class="text-blue-600">/</span> Terminal
</p>
</div>
{/* --- OBRAZAC S POVEZANIM SUBMITOM --- */}
<form onSubmit={handleSubmit} class="space-y-6">
{/* POLJE: E-MAIL */}
<div>
<label htmlFor="email" class="block text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 mb-2 ml-4 italic">
Korisnički E-mail
</label>
<input
type="email"
id="email"
name="email"
required
disabled={loading}
placeholder="serviser@tvrtka.hr"
class="w-full px-6 py-4 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-blue-600 rounded-2xl outline-none transition-all text-gray-900 dark:text-white font-bold italic shadow-inner disabled:opacity-50"
/>
</div>
{/* POLJE: LOZINKA */}
<div>
<label htmlFor="password" class="block text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 mb-2 ml-4 italic">
Lozinka
</label>
<input
type="password"
id="password"
name="password"
required
disabled={loading}
placeholder="••••••••"
class="w-full px-6 py-4 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-blue-600 rounded-2xl outline-none transition-all text-gray-900 dark:text-white font-bold tracking-[0.3em] shadow-inner disabled:opacity-50"
/>
</div>
{/* REAKTIVNI GUMB SA STILOVIMA I ANIMACIJOM UCITAVANJA */}
<div class="pt-2">
<button
type="submit"
disabled={loading}
class="w-full py-5 px-6 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800/50 text-white text-xs font-black uppercase tracking-widest rounded-2xl transition-all disabled:opacity-50 active:scale-[0.98] shadow-lg shadow-blue-500/20 flex items-center justify-center gap-3 italic cursor-pointer disabled:cursor-not-allowed"
>
{loading ? (
<>
<i class="fa-solid fa-circle-notch animate-spin text-sm"></i>
<span>AUTENTIFIKACIJA...</span>
</>
) : (
<>
<i class="fa-solid fa-bolt"></i>
<span>Pokreni Sustav</span>
</>
)}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,77 @@
---
// src/components/Gallery.astro
import { Image } from 'astro:assets';
import 'photoswipe/style.css';
interface Props {
// Prilagođeno tvom serializeru: 'url' je zapravo 'slika' u JSON-u
images: {
slika: string;
opis?: string;
width?: number;
height?: number;
}[];
galleryId: string;
}
const { images = [], galleryId } = Astro.props;
// Filtriramo važeće slike (pazimo na naziv polja 'slika' iz serializera)
const validImages = images.filter(img => img && img.slika);
---
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id={galleryId}>
{validImages.map((img) => (
<a
href={img.slika}
data-pswp-width={img.width || 1600}
data-pswp-height={img.height || 1200}
target="_blank"
class="group relative block aspect-square overflow-hidden rounded-[2rem] bg-gray-100 dark:bg-gray-800 border border-gray-100 dark:border-gray-700 shadow-sm"
>
<Image
src={img.slika}
alt={img.opis || "Detalj kvara"}
width={400}
height={400}
class="object-cover h-full w-full transition-transform duration-500 group-hover:scale-110"
/>
<!-- Overlay s opisom ako postoji -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center text-white p-4 text-center">
<i class="fa-solid fa-expand text-xl mb-2"></i>
{img.opis && <span class="text-[10px] font-black uppercase tracking-widest">{img.opis}</span>}
</div>
</a>
))}
</div>
{validImages.length === 0 && (
<div class="p-12 border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-[2rem] text-center">
<i class="fa-solid fa-images text-gray-200 dark:text-gray-700 text-3xl mb-3"></i>
<p class="text-gray-400 text-xs font-medium italic">Nema priložene foto dokumentacije.</p>
</div>
)}
<script>
import PhotoSwipeLightbox from 'photoswipe/lightbox';
const initPhotoSwipe = () => {
// Hvata sve galerije, neovisno o prefiksu, važno je da imaju ID
const lightboxes = document.querySelectorAll('.grid[id]');
lightboxes.forEach((el) => {
const lightbox = new PhotoSwipeLightbox({
gallery: `#${el.id}`,
children: 'a',
pswpModule: () => import('photoswipe'),
// Dodajemo opciju za kretanje kotačićem miša
wheelToZoom: true
});
lightbox.init();
});
};
// Inicijalizacija pri učitavanju i pri Astro navigaciji (View Transitions)
initPhotoSwipe();
document.addEventListener('astro:after-swap', initPhotoSwipe);
</script>

View File

@@ -0,0 +1,47 @@
// src/components/Gallery.jsx
import { h } from 'preact';
import { useStore } from '@nanostores/preact';
import { $offlineStatus } from '../stores/operativaStore';
export default function Gallery({ images = [] }) {
const isOffline = useStore($offlineStatus);
if (images.length === 0) return null;
return (
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{images.map((img) => {
const originalniUrl = img.slika;
// Generiramo identičan URL kakav je pohranjen u Cache Storageu
const optimiziraniUrl = `/api/operativa/proxy-image/?url=${encodeURIComponent(originalniUrl)}&w=600`;
return (
<div
key={img.id}
class="group relative aspect-square bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden shadow-md transition-all hover:border-blue-500/40"
>
{/* Slika s on-the-fly optimizacijom i automatskom offline podrškom */}
<img
src={optimiziraniUrl}
alt="Dokumentacija kvara"
loading="lazy"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
onError={(e) => {
// Potpuni fallback ako cache izgori ili slika fali, a imamo mrežu - prikaži original
if (!isOffline) e.target.src = originalniUrl;
}}
/>
{/* Mali vizualni indikator ako radimo u offline načinu rada */}
{isOffline && (
<div class="absolute bottom-2 right-2 px-1.5 py-0.5 bg-gray-950/80 border border-gray-800 text-[8px] text-amber-400 font-bold uppercase rounded tracking-wider backdrop-blur-sm">
<i class="fa-solid fa-cloud-meatball mr-1"></i>Offline Cache
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,114 @@
---
// src/components/GenericKarticaItem.astro
import { getStatusColorClass } from "../utils/ui";
interface Props {
href: string;
status: string;
statusBojaClass?: string;
ikona: string;
naslov: string;
subNaslov?: string;
metaTekst: string;
statusPrikaz: string;
bojaTeme?: "blue" | "indigo" | "emerald" | "red"; // Podrška za teme
}
const {
href,
status,
statusBojaClass,
ikona,
naslov,
subNaslov,
metaTekst,
statusPrikaz,
bojaTeme = "blue"
} = Astro.props;
// Mapiranje tema na Tailwind klase
const themes = {
blue: {
hover: "hover:bg-blue-50/30 dark:hover:bg-blue-900/10",
text: "group-hover:text-blue-600 dark:group-hover:text-blue-400",
iconBg: "group-hover:bg-blue-600",
arrow: "group-hover:text-blue-600"
},
indigo: {
hover: "hover:bg-indigo-50/30 dark:hover:bg-indigo-900/10",
text: "group-hover:text-indigo-600 dark:group-hover:text-indigo-400",
iconBg: "group-hover:bg-indigo-600",
arrow: "group-hover:text-indigo-600"
},
emerald: {
hover: "hover:bg-emerald-50/30 dark:hover:bg-emerald-900/10",
text: "group-hover:text-emerald-600 dark:group-hover:text-emerald-400",
iconBg: "group-hover:bg-emerald-600",
arrow: "group-hover:text-emerald-400"
}
};
const activeTheme = themes[bojaTeme] || themes.blue;
const resolvedStatusColor = statusBojaClass || getStatusColorClass(status);
---
<a
href={href}
class:list={[
"nalog-item flex items-center justify-between p-8 border-b border-gray-50 dark:border-gray-700 last:border-0 group transition-all duration-300",
activeTheme.hover
]}
data-status={status}
>
<div class="flex items-center gap-6 text-left">
<!-- Ikona s dinamičkom bojom na hoveru -->
<div class:list={[
"w-14 h-14 rounded-2xl flex items-center justify-center text-xl shadow-inner transition-all duration-300 bg-gray-50 dark:bg-gray-900 text-gray-400 group-hover:text-white",
activeTheme.iconBg
]}>
<i class:list={["fa-solid", ikona, status === 'u_radu' ? 'animate-pulse' : '']}></i>
</div>
<div class="flex flex-col">
<div class="flex items-center gap-3">
<span class:list={[
"font-black text-gray-900 dark:text-white text-xl uppercase tracking-tighter italic transition-colors leading-none",
activeTheme.text
]}>
{naslov}
</span>
{subNaslov && (
<span class="text-[10px] font-black bg-gray-900 text-white px-2 py-0.5 rounded border border-gray-700 uppercase tracking-[0.2em]">
{subNaslov}
</span>
)}
</div>
<span class="text-gray-400 text-[10px] font-black uppercase tracking-widest mt-2 italic leading-none">
{metaTekst}
</span>
</div>
</div>
<div class="flex items-center gap-8">
<!-- Status indikator desno -->
<div class="hidden lg:flex flex-col items-end text-right">
<div class="flex items-center gap-2 mb-1">
<div class:list={["w-2 h-2 rounded-full", resolvedStatusColor]}></div>
<span class="text-[8px] font-black uppercase text-gray-400 tracking-[0.2em]">Status</span>
</div>
<span class:list={[
"text-xs font-black uppercase italic transition-colors",
status === 'zavrseno' || status === 'naplaceno' || status === 'aktivan' ? 'text-emerald-500' : activeTheme.text
]}>
{statusPrikaz}
</span>
</div>
<!-- Strelica -->
<i class:list={[
"fa-solid fa-arrow-right-long text-gray-200 group-hover:translate-x-2 transition-all duration-300",
activeTheme.arrow
]}></i>
</div>
</a>

View File

@@ -0,0 +1,192 @@
---
// src/components/Kalendar.astro
import { fetchCalendarEvents } from "../../lib/api";
import { getStatusColorClass } from "../../utils/ui";
// 1. Dohvat podataka s Django API-ja kroz tvoj centralizirani lib
const apiEvents = await fetchCalendarEvents();
// 2. Grupiranje događaja po datumu (YYYY-MM-DD)
const eventsByDate = apiEvents.reduce((acc, event) => {
if (!event.start) return acc;
const dateKey = new Date(event.start).toISOString().split('T')[0];
if (!acc[dateKey]) acc[dateKey] = [];
// Koristimo čista, razdvojena polja s novog Django serializera
acc[dateKey].push({
id: event.id,
title: event.title,
opis: event.opis_cisti, // Čisti opis kvara s backenda
start: event.start,
tip: (event.tip || event.status || 'planirano').toLowerCase(),
radni_nalog: event.radni_nalog || event.id,
je_radni_nalog: event.je_radni_nalog !== undefined ? event.je_radni_nalog : true,
izvrsitelj_ime: event.izvrsitelj_ime,
klijent: event.klijent || null,
stroj: event.stroj || null,
vozilo: event.vozilo_naziv // Čisti naziv vozila s backenda
});
return acc;
}, {});
// Postavke za trenutni prikaz (Svibanj 2026)
const daysInMonth = 31;
const firstDayOffset = 5; // Petak
const currentYearMonth = "2026-05";
---
<div class="w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-2xl rounded-[3rem] border border-gray-100 dark:border-gray-700 overflow-hidden font-sans transition-all">
<div class="flex justify-between items-center bg-gray-50/50 dark:bg-gray-900/50 px-8 py-6 border-b border-gray-100 dark:border-gray-700">
<button class="text-gray-400 hover:text-blue-600 transition-colors text-2xl font-black">&lsaquo;</button>
<div class="text-center">
<h2 class="text-xl font-black uppercase tracking-tighter italic leading-none">Svibanj 2026</h2>
<span class="text-[10px] text-blue-600 font-bold uppercase tracking-[0.2em] mt-1 italic">Raspored servisa</span>
</div>
<button class="text-gray-400 hover:text-blue-600 transition-colors text-2xl font-black">&rsaquo;</button>
</div>
<div id="calendar-body">
<div class="grid grid-cols-7 text-center text-[10px] text-gray-400 dark:text-gray-500 uppercase font-black tracking-[0.2em] py-4 border-b border-gray-50 dark:border-gray-700/50">
<div>Ned</div><div>Pon</div><div>Uto</div><div>Sri</div><div>Čet</div><div>Pet</div><div>Sub</div>
</div>
<div class="grid grid-cols-7 text-center" id="days-grid">
{Array.from({ length: firstDayOffset }).map(() => (
<div class="py-6 border-b border-r border-gray-50 dark:border-gray-700/10 opacity-0"></div>
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const dateKey = `${currentYearMonth}-${day.toString().padStart(2, '0')}`;
const dayEvents = eventsByDate[dateKey] || [];
return (
<div
class="day-cell py-6 relative cursor-pointer hover:bg-blue-50/50 dark:hover:bg-gray-700/30 transition-all border-b border-r border-gray-50 dark:border-gray-700/30 group"
data-day={day}
data-events={JSON.stringify(dayEvents)}
>
<span class={`text-3xl font-black italic tracking-tighter transition-colors ${dayEvents.length > 0 ? 'text-gray-900 dark:text-white' : 'text-gray-300 dark:text-gray-600'} group-hover:text-blue-600`}>
{day.toString().padStart(2, '0')}
</span>
<div class="flex justify-center gap-1 mt-1 h-1.5">
{dayEvents.map(event => (
<div class={`w-1.5 h-1.5 rounded-full ${getStatusColorClass(event.tip)}`}></div>
))}
</div>
</div>
);
})}
<div id="event-panel" class="hidden col-span-7 bg-blue-600 text-white p-8 relative overflow-visible shadow-2xl">
<div class="absolute -top-2 left-0 w-full h-2">
<div id="panel-arrow" class="absolute w-0 h-0 border-l-[12px] border-l-transparent border-r-[12px] border-r-transparent border-b-[12px] border-b-blue-600 transition-all duration-300"></div>
</div>
<div id="event-list" class="flex flex-col gap-6 w-full">
</div>
</div>
</div>
</div>
</div>
<script>
function initKalendar() {
const grid = document.getElementById('days-grid');
const panel = document.getElementById('event-panel');
const arrow = document.getElementById('panel-arrow');
const eventList = document.getElementById('event-list');
const dayCells = document.querySelectorAll('.day-cell');
if (!grid || !panel) return;
dayCells.forEach(cell => {
cell.addEventListener('click', () => {
const eventsData = JSON.parse(cell.getAttribute('data-events') || '[]');
if (eventsData.length > 0) {
const allItems = Array.from(grid.children);
const index = allItems.indexOf(cell);
// 1. Pozicioniranje strelice
const colIndex = index % 7;
const percentage = (colIndex * (100 / 7)) + (100 / 7 / 2);
arrow.style.left = `calc(${percentage}% - 12px)`;
// 2. Injekcija panela na kraj tjedna (redka)
const rowEndIndex = Math.floor(index / 7) * 7 + 6;
const targetCell = allItems[Math.min(rowEndIndex, allItems.length - 1)];
// 3. Generiranje liste (Struktura točno prema tvojoj ispravno.png skici)
eventList.innerHTML = eventsData.map(ev => {
const klijentStroj = [ev.klijent, ev.stroj].filter(Boolean).join(' | ');
const infoLinija = klijentStroj
? `<div class="text-[13px] opacity-80 font-medium tracking-tight mt-1"><i class="fa-solid fa-industry text-[11px] mr-1 opacity-60"></i> ${klijentStroj}</div>`
: '';
// Čisti naziv vozila u zasebnom redu (bez labela "Vozilo:")
const voziloRed = ev.vozilo
? `<p class="text-sm font-medium mt-1 text-white opacity-90">
${ev.vozilo}
</p>`
: '';
// ISTAKNUTO: Opis kvara u posebnom uokvirenom kontejneru radi boljeg kontrasta
const opisRed = `
<div class="mt-3 p-3 bg-black/15 rounded-xl border border-white/10 text-left">
<p class="text-[10px] font-black uppercase tracking-widest text-amber-300 opacity-90 mb-1">
Opis kvara:
</p>
<p class="text-sm font-medium text-white leading-relaxed">
${ev.opis}
</p>
</div>
`;
return `
<div class="flex items-start gap-5 border-b border-white/20 pb-6 last:border-0 last:pb-0 group/item">
<a href="/operativa/radni-nalozi/${ev.radni_nalog}" class="bg-white/10 hover:bg-white hover:text-blue-600 text-white p-4 rounded-2xl transition-all shadow-lg shrink-0 group-hover/item:scale-105 active:scale-95">
<i class="fa-solid ${ev.je_radni_nalog ? 'fa-file-signature' : 'fa-wrench'} text-2xl"></i>
</a>
<div class="flex flex-col flex-1 text-left">
<div class="flex justify-between items-start">
<a href="/operativa/radni-nalozi/${ev.radni_nalog}" class="hover:underline">
<span class="font-black uppercase text-2xl tracking-tighter italic leading-none">${ev.title}</span>
</a>
</div>
${infoLinija}
${voziloRed}
${opisRed}
<div class="mt-4 flex flex-wrap gap-3">
<span class="text-[10px] font-black uppercase tracking-widest bg-black/30 px-3 py-1.5 rounded-lg flex items-center gap-2">
<i class="fa-solid fa-user-gear opacity-50"></i> ${ev.izvrsitelj_ime}
</span>
<span class="text-[10px] font-black uppercase tracking-widest bg-black/30 px-3 py-1.5 rounded-lg flex items-center gap-2">
<i class="fa-solid fa-clock opacity-50"></i> ${new Date(ev.start).toLocaleTimeString('hr-HR', {hour: '2-digit', minute:'2-digit'})} h
</span>
</div>
</div>
</div>
`;
}).join('');
targetCell.after(panel);
panel.classList.remove('hidden');
} else {
panel.classList.add('hidden');
}
});
});
}
initKalendar();
document.addEventListener('astro:after-swap', initKalendar);
</script>

View File

@@ -0,0 +1,59 @@
---
// src/components/ListaStrojeva.astro
import NaslovList from "./NaslovList.astro";
import GenericKarticaItem from "./GenericKarticaItem.astro";
import { getStatusColorClass, formatStatus } from "../utils/ui";
interface Props {
strojevi: any[];
naslov?: string;
prikaziNaslov?: boolean;
}
const {
strojevi = [],
naslov = "Tehničke jedinice",
prikaziNaslov = true
} = Astro.props;
// Statistika za NaslovList (npr. koliko ih je aktivno ili na servisu ako imaš te podatke)
const ukupnoStrojeva = strojevi.length;
// Pretpostavljamo da strojevi imaju status, ako nemaju, možemo staviti fiksne labele
const uRadu = strojevi.filter(s => s.status === 'u_radu').length;
---
<div class="space-y-6">
{prikaziNaslov && (
<NaslovList
naslov={naslov}
ukupno={ukupnoStrojeva}
label1="Ukupno"
count1={ukupnoStrojeva}
label2="Aktivno"
count2={uRadu}
/>
)}
<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="strojevi-list">
{strojevi.length > 0 ? strojevi.map((s) => (
<GenericKarticaItem
href={`/fleet/strojevi/${s.id}`}
status={s.status || 'aktivan'}
statusBojaClass={getStatusColorClass(s.status || 'aktivan')}
ikona="fa-screwdriver-wrench"
naslov={s.naziv}
subNaslov={s.serijski_broj}
metaTekst={`${s.vlasnik_naziv} | Radni sati: ${parseFloat(s.radni_sati || 0).toLocaleString('hr-HR')} h`}
statusPrikaz={formatStatus(s.status || 'aktivan')}
/>
)) : (
<div class="p-20 text-center italic text-gray-400 text-[10px] uppercase tracking-widest leading-none">
<i class="fa-solid fa-box-open text-3xl text-gray-100 dark:text-gray-700 mb-4 block"></i>
Nema dostupnih tehničkih jedinica
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,110 @@
---
// src/components/LoginForm.astro
import Button from "./Button.astro";
---
<div class="max-w-md mx-auto w-full p-10 bg-white dark:bg-gray-900 rounded-[3rem] border border-gray-100 dark:border-gray-800 shadow-2xl shadow-blue-500/10">
<div class="mb-10 text-center sm:text-left">
<div class="inline-flex w-14 h-14 bg-blue-600 rounded-2xl items-center justify-center shadow-lg shadow-blue-500/20 mb-6 rotate-3 sm:-ml-2">
<i class="fa-solid fa-lock text-white text-xl"></i>
</div>
<h2 class="text-4xl font-black text-gray-900 dark:text-white uppercase tracking-tighter italic leading-none">Prijava</h2>
<p class="text-gray-500 dark:text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] mt-3 opacity-70 italic">
ServisLog <span class="text-blue-600">/</span> Terminal
</p>
</div>
<form id="login-form" class="space-y-6">
<div>
<label for="email" class="block text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 mb-2 ml-4 italic">Korisnički E-mail</label>
<input
type="email"
id="email"
name="email"
required
class="w-full px-6 py-4 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-blue-600 rounded-2xl outline-none transition-all text-gray-900 dark:text-white font-bold italic shadow-inner"
placeholder="serviser@tvrtka.hr"
/>
</div>
<div>
<label for="password" class="block text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 mb-2 ml-4 italic">Lozinka</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-6 py-4 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-blue-600 rounded-2xl outline-none transition-all text-gray-900 dark:text-white font-bold tracking-[0.3em] shadow-inner"
placeholder="••••••••"
/>
</div>
<div class="pt-2">
<Button
type="submit"
id="login-btn"
variant="primary"
class="w-full !py-5 italic"
>
<i class="fa-solid fa-bolt" slot="icon"></i>
Pokreni Sustav
</Button>
</div>
</form>
</div>
<script>
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;
loginForm?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!btn) return;
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-3"></i> AUTENTIFIKACIJA...';
const formData = new FormData(loginForm);
const email = formData.get('email') as string;
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.'
}));
// 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 {
// @ts-ignore
window.showToast?.(result.error || "Pristup odbijen: Neispravni podaci.", "error");
btn.disabled = false;
btn.innerHTML = originalContent;
}
} catch (err) {
console.error("Login krah na klijentu:", err);
// @ts-ignore
window.showToast?.("Sustav nedostupan. Provjerite vezu.", "error");
btn.disabled = false;
btn.innerHTML = originalContent;
}
});
</script>

View File

@@ -0,0 +1,193 @@
---
// src/components/RadniNalogLista.astro
import NaslovList from "./NaslovList.astro";
interface Props {
limit?: number;
naslov?: string;
prikaziNaslov?: boolean;
}
const {
limit = 0,
naslov = "Zadnje aktivnosti",
prikaziNaslov = true,
} = Astro.props;
---
<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
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>
<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

@@ -0,0 +1,158 @@
// src/components/ui/Navbar.jsx
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import siteConfig from '../../data/site.json';
import { getBaseUrl } from '../../lib/nav.js';
import { $currentUser, logoutKorisnika } from '../../stores/appState';
export default function Navbar() {
// 1. Reaktivno slušamo stanje ulogiranog mehatroničara/korisnika
const currentUser = useStore($currentUser);
// 2. Lokalno stanje za otvaranje i zatvaranje mobilnog izbornika
const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef(null);
const buttonRef = useRef(null);
// Dohvaćamo trenutnu stazu u pregledniku za aktivne klase (samo na klijentu)
const trenutnaStaza = typeof window !== 'undefined' ? window.location.pathname : '';
// Zatvaranje izbornika na klik izvan komponente (Higijena sučelja)
useEffect(() => {
if (!isMenuOpen) return;
const handleClickOutside = (e) => {
if (
menuRef.current && !menuRef.current.contains(e.target) &&
buttonRef.current && !buttonRef.current.contains(e.target)
) {
setIsMenuOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [isMenuOpen]);
// Razbijamo naslov na bazi tvog vizualnog stila (prva riječ plava ili naglašena)
const naslovPrvaRijec = siteConfig.title.split(' ')[0];
const naslovOstatak = siteConfig.title.split(' ').slice(1).join(' ');
return (
<nav class="sticky top-0 z-[100] w-full bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-100 dark:border-gray-800">
<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={getBaseUrl('/')} 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">
<i class="fa-solid fa-screwdriver-wrench text-white text-lg"></i>
</div>
<span class="text-xl font-black tracking-tighter text-gray-900 dark:text-white uppercase italic">
{naslovPrvaRijec}<span class="text-blue-600">{naslovOstatak ? ` ${naslovOstatak}` : ''}</span>
</span>
</a>
</div>
{/* --- DESNA STRANA: DINAMIČKA REAKTIVNA AUTENTIFIKACIJA --- */}
<div class="flex items-center gap-4">
{currentUser ? (
/* STANJE: MEHATRONIČAR JE LOGIRAN -> Prikaži Menu Gumb */
<button
ref={buttonRef}
onClick={(e) => {
e.stopPropagation();
setIsMenuOpen(!isMenuOpen);
}}
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"
aria-label="Izbornik"
>
<i class={`fa-solid ${isMenuOpen ? 'fa-xmark' : 'fa-bars-staggered'} text-xl`}></i>
</button>
) : (
/* STANJE: ANONYMOUS -> Prikaži Gumb za Prijavu */
<a href={getBaseUrl('/login')} class="inline-block">
<button 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>
</a>
)}
</div>
</div>
</div>
{/* --- REAKTIVNI MOBILNI / AKCIJSKI IZBORNIK --- */}
{currentUser && isMenuOpen && (
<div
ref={menuRef}
class="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 shadow-blue-500/5 z-[100] transition-all animate-fade-in-up"
>
<div class="flex flex-col gap-2">
{/* LINKOVI IZ SITE.JSON UZ DINAMIČKU PUTANJU ZA SERVISERA */}
{siteConfig.navigation.map((item) => {
let konacniUrl = item.url;
// 🚀 DINAMIČKI POPRAVAK PUTANJE: Ako je link namijenjen serviserima,
// automatski ga preusmjeravamo na točan ID ulogiranog korisnika
if (item.url === '/operativa/serviseri' && currentUser?.id) {
konacniUrl = `/operativa/serviseri/${currentUser.id}`;
}
const siguranUrl = getBaseUrl(konacniUrl);
// Provjera aktivnog stanja (bilo egzakto, bilo za dinamičke pod-rute)
const jeAktivan = trenutnaStaza === siguranUrl ||
(item.url === '/operativa/serviseri' && trenutnaStaza.includes('/operativa/serviseri'));
return (
<a
key={item.name}
href={siguranUrl}
onClick={() => setIsMenuOpen(false)}
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 ${
jeAktivan
? "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>
);
})}
{/* BRZA AKCIJA: NOVI NALOG */}
<div class="w-full mt-1 mb-2">
<a href={getBaseUrl('/operativa/radni-nalozi/novi')} onClick={() => setIsMenuOpen(false)} class="block">
<button 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 cursor-pointer border border-transparent">
<i class="fa-solid fa-plus mr-2"></i> Novi nalog
</button>
</a>
</div>
<hr class="my-2 border-gray-100/50 dark:border-gray-800/50" />
{/* AKCIJA: CENTRALNI LOGOUT */}
<div class="w-full">
<button
onClick={() => {
setIsMenuOpen(false);
logoutKorisnika(); // 🚀 DRY TRIJUMF: Poziva našu centralnu akciju iz trgovine!
}}
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>
);
}

View File

@@ -0,0 +1,49 @@
// src/components/ui/Navbar.jsx
import { h } from 'preact';
import siteConfig from '../../data/site.json';
import { getBaseUrl } from '../../lib/nav.js';
export default function Navbar() {
// Početna ruta se generira automatski i sigurno prosljeđivanjem korijena
const pocetnaRuta = getBaseUrl('/');
// Provjeravamo trenutnu stazu u pregledniku (samo ako smo na klijentu)
const trenutnaStaza = typeof window !== 'undefined' ? window.location.pathname : '';
return (
<nav class="fixed top-0 left-0 right-0 z-40 bg-gray-900/80 border-b border-gray-800 backdrop-blur-md py-4 px-6 flex items-center justify-between">
{/* LOGO / NASLOV SUSTAVA */}
<a
href={pocetnaRuta}
class="font-black tracking-tighter text-white uppercase italic text-sm hover:text-blue-500 transition-colors"
>
{siteConfig.title}
</a>
{/* DINAMIČKI LINKOVI IZ SITE.JSON */}
<div class="flex items-center space-x-6 text-xs font-black uppercase tracking-wider italic">
{siteConfig.navigation
.filter(item => item.welcomeHeaderDisplay)
.map(item => {
const siguranUrl = getBaseUrl(item.url);
const jeAktivan = trenutnaStaza === siguranUrl;
return (
<a
key={item.name}
href={siguranUrl}
class={`transition-colors tracking-widest ${
jeAktivan
? 'text-blue-500 font-black underline underline-offset-4'
: 'text-gray-400 hover:text-white'
}`}
>
{item.name}
</a>
);
})
}
</div>
</nav>
);
}

View File

@@ -0,0 +1,179 @@
---
// src/components/Navbar.astro
import site from "../data/site.json";
import Button from "./Button.astro";
const currentPath = Astro.url.pathname;
---
<nav class="sticky top-0 z-[100] w-full bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-100 dark:border-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-20 items-center">
<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">
<i class="fa-solid fa-screwdriver-wrench text-white text-lg"></i>
</div>
<span class="text-xl font-black tracking-tighter text-gray-900 dark:text-white uppercase">
{site.title.split(' ')[0]}<span class="text-blue-600">{site.title.split(' ')[1] || ''}</span>
</span>
</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>
</div>
</div>
</div>
<div
id="mobile-menu"
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 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) => {
// 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"
>
<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" />
<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>
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 (!authSection) return;
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
const stanjeAutentifikacije = token ? 'authenticated' : 'anonymous';
// 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');
}
}
}
}
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();
});
}
// 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

@@ -0,0 +1,202 @@
---
// src/components/RadniNalogLista.astro
import NaslovList from "./NaslovList.astro";
interface Props {
limit?: number;
naslov?: string;
prikaziNaslov?: boolean;
}
const {
limit = 0,
naslov = "Zadnje aktivnosti",
prikaziNaslov = true,
} = Astro.props;
---
<div class="space-y-6 group" id="radni-nalozi-sekcija" data-state="loading">
{prikaziNaslov && (
<NaslovList
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-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>
<span id="rn-bridge" data-limit={limit} data-naslov={naslov}></span>
<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

@@ -0,0 +1,48 @@
---
// src/components/StatsGrid.astro
interface Props {
label: string;
value: string | number;
icon: string;
variant?: 'blue' | 'yellow' | 'emerald' | 'gray';
animate?: boolean;
filterValue?: string; // NOVO: npr. 'u_radu', 'planirano'...
class?: string;
}
const {
label,
value,
icon,
variant = 'blue',
animate = false,
filterValue = "",
class: className = ""
} = Astro.props;
const themes = {
blue: { container: "border-gray-100 dark:border-gray-700", iconBg: "bg-blue-50 dark:bg-blue-900/30", iconColor: "text-blue-600 dark:text-blue-400", labelColor: "text-gray-400" },
yellow: { container: "border-yellow-500/20 border-2", iconBg: "bg-yellow-500", iconColor: "text-white", labelColor: "text-yellow-600" },
emerald: { container: "border-gray-100 dark:border-gray-700", iconBg: "bg-emerald-50 dark:bg-emerald-900/30", iconColor: "text-emerald-600 dark:text-emerald-400", labelColor: "text-gray-400" },
gray: { container: "border-gray-100 dark:border-gray-700", iconBg: "bg-gray-100 dark:bg-gray-800", iconColor: "text-gray-500", labelColor: "text-gray-400" }
};
const theme = themes[variant];
---
<div
class={`stat-card bg-white dark:bg-gray-800 p-3 sm:p-6 rounded-2xl sm:rounded-[2.5rem] shadow-sm border flex flex-row items-center justify-center gap-2 sm:gap-5 cursor-pointer hover:scale-[1.02] active:scale-95 transition-all ${theme.container} ${className}`}
>
<div class={`p-1.5 sm:p-4 rounded-lg sm:rounded-2xl w-7 h-7 sm:w-16 sm:h-16 flex items-center justify-center flex-none ${theme.iconBg} ${theme.iconColor} ${animate ? 'animate-pulse' : ''}`}>
<i class={`fa-solid ${icon} text-xs sm:text-2xl`}></i>
</div>
<div class="flex flex-col justify-center">
<h3 class="text-sm sm:text-4xl font-black text-gray-900 dark:text-white leading-none tracking-tighter">
{value}
</h3>
<p class={`text-[6px] sm:text-xs font-black uppercase tracking-widest mt-0.5 sm:mt-1 ${theme.labelColor}`}>
{label}
</p>
</div>
</div>

View File

@@ -0,0 +1,67 @@
// src/components/ui/ToastContainer.jsx
import { h } from 'preact';
import { useStore } from '@nanostores/preact';
import { useEffect } from 'preact/hooks';
import { $toast, ukloniToast } from '../../stores/toastStore'; // Prilagodi putanju tvom toastStoreu
export default function ToastContainer() {
const toast = useStore($toast);
// Automatsko zatvaranje toasta nakon 4 sekunde (Higijena sučelja)
useEffect(() => {
if (toast) {
const timer = setTimeout(() => {
ukloniToast();
}, 3000);
return () => clearTimeout(timer);
}
}, [toast]);
if (!toast) return null;
// Dinamičko dodjeljivanje ikona i boja ovisno o tipu (success / error)
const jeUspjeh = toast.type === 'success';
return (
<div class="fixed bottom-6 right-6 z-50 max-w-sm w-full animate-fade-in-up">
<div
class={`p-4 rounded-2xl border backdrop-blur-md shadow-2xl flex items-start gap-3 transition-all duration-300 ${
jeUspjeh
? 'bg-emerald-950/90 border-emerald-500/30 text-emerald-200 shadow-emerald-500/5'
: 'bg-red-950/90 border-red-500/30 text-red-200 shadow-red-500/5'
}`}
>
{/* Ikonica s animacijom */}
<div class={`w-6 h-6 rounded-lg flex items-center justify-center shrink-0 ${
jeUspjeh ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'
}`}>
{jeUspjeh ? (
<i class="fa-solid fa-circle-check text-sm animate-pulse"></i>
) : (
<i class="fa-solid fa-circle-exclamation text-sm animate-bounce"></i>
)}
</div>
{/* Sadržaj obavijesti */}
<div class="flex-1 pt-0.5">
<p class="text-[10px] font-black uppercase tracking-widest opacity-60 italic">
{jeUspjeh ? 'Sustav / Obavijest' : 'Sustav / Greška'}
</p>
<p class="text-xs font-bold tracking-wide mt-1 leading-snug text-white">
{toast.message}
</p>
</div>
{/* Gumb za ručno zatvaranje */}
<button
onClick={ukloniToast}
class="text-gray-400 hover:text-white transition-colors p-1 rounded-lg hover:bg-white/5 cursor-pointer"
aria-label="Zatvori"
>
<i class="fa-solid fa-xmark text-xs"></i>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
---
// src/components/WelcomeHeader.astro
import site from "../data/site.json";
const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
const currentPageData = site.navigation.find(item => item.url === currentPath);
console.log("Current Path:", currentPath);
const {
welcomeHeaderTextH1 = "Dobrodošli",
welcomeHeaderTextH1dodatno = "",
welcomeHeaderPodnaslov = "Pregled sustava",
welcomeHeaderPovratniURL = "",
welcomeHeaderDisplay = true
} = currentPageData || {};
---
{welcomeHeaderDisplay && (
<header class="mb-12 px-2">
{welcomeHeaderPovratniURL && (
<a href={welcomeHeaderPovratniURL} class="text-blue-600 text-[10px] font-black uppercase tracking-widest no-underline flex items-center gap-2 mb-6 hover:opacity-70 transition-opacity italic">
<i class="fa-solid fa-arrow-left text-[8px]"></i> Povratak
</a>
)}
<div class="text-left">
<h1 class="text-4xl sm:text-6xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none italic">
{welcomeHeaderTextH1} <span class="text-blue-600"> {welcomeHeaderTextH1dodatno}</span>
</h1>
<p class="text-gray-500 dark:text-gray-400 mt-4 font-medium italic text-lg leading-tight">
{welcomeHeaderPodnaslov}
</p>
</div>
</header>
)}

View File

@@ -0,0 +1,32 @@
// src/components/auth/AuthStatus.jsx
import { h } from 'preact';
import { useStore } from '@nanostores/preact';
import { $currentUser, logoutKorisnika } from '../../stores/appState'; // 🚀 Uvozimo i atom i akciju
import Button from '../Button.jsx';
export default function AuthStatus() {
const currentUser = useStore($currentUser);
const isLoggedIn = !!currentUser;
return isLoggedIn ? (
<Button
variant="danger"
onClick={logoutKorisnika} // 🚀 DRY: Samo pozivamo spremnu funkciju
class="!py-2 !px-4 uppercase text-[10px] tracking-widest italic font-black flex items-center space-x-2"
>
<i class="fa-solid fa-user-gear mr-1 text-red-400"></i>
<span>
Logout ({currentUser.first_name ? `${currentUser.first_name}` : 'Servis'})
</span>
</Button>
) : (
<a href="/login" class="inline-block">
<Button
variant="primary"
class="!py-2 !px-4 uppercase text-[10px] tracking-widest italic font-black"
>
Prijava
</Button>
</a>
);
}

View File

@@ -0,0 +1,107 @@
{
"title": "ServisLog",
"description": "Profesionalni sustav za upravljanje servisnim operacijama",
"url": "https://servislog.hr",
"author": "Servis tim",
"theme": {
"mode": "dark",
"color": "blue"
},
"navigation": [
{
"name": "Dashboard",
"url": "/",
"icon": "fa-chart-line",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Kontrolna",
"welcomeHeaderTextH1dodatno": " ploča",
"welcomeHeaderPodnaslov": "Pregled ključnih informacija u stvarnom vremenu",
"welcomeHeaderPovratniURL": ""
},
{
"name": "Radni nalozi",
"url": "/operativa/radni-nalozi",
"icon": "fa-file-signature",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Radni",
"welcomeHeaderTextH1dodatno": " nalozi",
"welcomeHeaderPodnaslov": "Pregled ključnih informacija u stvarnom vremenu",
"welcomeHeaderPovratniURL": "/"
},
{
"name": "Vozni Park",
"url": "/fleet/vozila",
"icon": "fa-truck-pickup",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Vozni",
"welcomeHeaderTextH1dodatno": " park",
"welcomeHeaderPodnaslov": "Sustavna evidencija i nadzor mobilnih resursa",
"welcomeHeaderPovratniURL": "/"
},
{
"name": "Registrirani strojevi",
"url": "/fleet/strojevi",
"icon": "fa-truck-pickup",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Vozni",
"welcomeHeaderTextH1dodatno": " park",
"welcomeHeaderPodnaslov": "Sustavna evidencija i nadzor mobilnih resursa",
"welcomeHeaderPovratniURL": "/"
},
{
"name": "Klijenti",
"url": "/kupci/svi",
"icon": "fa-address-book",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Pregled",
"welcomeHeaderTextH1dodatno": " klijenata",
"welcomeHeaderPodnaslov": "Upravljanje bazom korisnika i partnera",
"welcomeHeaderPovratniURL": "/"
},
{
"name": "Kalendar",
"url": "/kalendar-dogadaja",
"icon": "fa-calendar-days",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Kalendar",
"welcomeHeaderTextH1dodatno": " događaja",
"welcomeHeaderPodnaslov": "Pregled operacija u stvarnom vremenu",
"welcomeHeaderPovratniURL": "/"
},
{
"name": "Login",
"url": "/login",
"icon": "fa-calendar-days",
"welcomeHeaderDisplay": false,
"welcomeHeaderTextH1": "Prijava",
"welcomeHeaderTextH1dodatno": " korisnika",
"welcomeHeaderPodnaslov": "Pristup sustavu za ovlaštene korisnike",
"welcomeHeaderPovratniURL": ""
},
{
"name": "Novi Radni Nalog",
"url": "/operativa/radni-nalozi/novi",
"icon": "fa-calendar-days",
"welcomeHeaderDisplay": true,
"welcomeHeaderTextH1": "Novi",
"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": {
"base": "PUBLIC_API_URL",
"auth": "/token/",
"me": "/users/me/"
}
}

View File

@@ -0,0 +1,57 @@
---
// src/layouts/Layout.astro
import siteConfig from '../data/site.json';
import { getBaseUrl } from '../lib/nav.js';
import ToastContainer from '../components/ToastContainer.jsx';
import AuthStatus from '../components/auth/AuthStatus.jsx';
import Navbar from '../components/Navbar/index.jsx';
import { ClientRouter } from 'astro:transitions';
import "../styles/global.css";
interface Props {
title: string;
description?: string;
}
const { title, description: stranicaDescription } = Astro.props;
const metaDescription = stranicaDescription || siteConfig.description || "Glavni terminal sustava ServisLog.";
---
<!doctype html>
<html lang="hr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={metaDescription} />
<ClientRouter />
<title>{title} | {siteConfig.title}</title>
<link rel="stylesheet" href={getBaseUrl('/css/all.min.css')} />
</head>
<body>
<Navbar client:load />
<ToastContainer client:only="preact" transition:persist />
<header>
<AuthStatus client:only="preact" transition:persist />
</header>
<main transition:animate="fade" class="flex-grow w-full max-w-7xl mx-auto pt-24 pb-12 px-4 sm:px-6 lg:px-8">
<slot />
</main>
<footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-6 text-center text-sm text-gray-500">
&copy; {new Date().getFullYear()} {siteConfig.title}. Sva prava pridržana.
</footer>
<div id="toast-container" style="position: fixed; bottom: 20px; right: 20px;"></div>
<script is:inline src={getBaseUrl('/js/flowbite.min.js')} defer></script>
</body>
</html>

578
001.FRONTEND/src/lib/api.js Normal file
View File

@@ -0,0 +1,578 @@
// src/lib/api.js
// 1. Osiguravamo da API_BASE uvijek završava s točno jednom kosom crtom
const RAW_BASE = import.meta.env.PUBLIC_API_URL;
// Dodajemo "export" kako bi je Astro Actions mogle uvoziti
export const API_BASE = RAW_BASE.endsWith('/') ? RAW_BASE : `${RAW_BASE}/`;
/**
* Vraća ispravan korijenski URL ovisno o tome izvršava li se kôd na serveru ili klijentu.
*/
export function getBaseApiUrl() {
// 1. Provjeravamo jesmo li u pregledniku (Klijent)
if (typeof window !== 'undefined') {
// Na klijentu puštamo relativni URL kako bi Vite proxy (/api/...) odradio posao
return '';
}
// 2. Ako nismo u pregledniku, znači da smo na Astro serveru (SSR unutar Docker-a)
// Koristimo Docker interni naziv servisa 'backend' na portu 8000
return 'http://backend:8000';
}
/**
* 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 = {}, 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 s terena)
const isFormData = bodyData instanceof FormData;
// Ako NIJE FormData, šaljemo standardni JSON content type
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
/**
* Centralizirana obrada odgovora s Toast podrškom
*/
async function handleResponse(res) {
if (res.status === 401) {
console.warn("Token istekao ili je nevažeći.");
}
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
console.error("API Error Response:", errorData);
let msg = "Greška pri sinkronizaciji podataka.";
if (errorData.detail) {
msg = errorData.detail;
}
else if (typeof errorData === 'object' && errorData !== null) {
const kljuceviGresaka = Object.keys(errorData);
if (kljuceviGresaka.length > 0) {
const prvoPolje = kljuceviGresaka[0];
const greskaVrijednost = errorData[prvoPolje];
if (Array.isArray(greskaVrijednost) && greskaVrijednost.length > 0) {
msg = `${prvoPolje}: ${greskaVrijednost[0]}`;
} else if (typeof greskaVrijednost === 'string') {
msg = `${prvoPolje}: ${greskaVrijednost}`;
}
}
}
if (typeof window !== 'undefined') {
window.showToast?.(msg, "error");
}
return null;
}
// VRAĆA PARSIRAN OBJEKT - Stream je zatvoren nakon ove linije!
return await res.json();
}
// --- RUTE ---
export const routes = {
// Autentifikacija
login: () => 'token/',
trenutniKorisnik: () => 'users/me/',
// Korisnici i operativni tehničari
serviseriSvi: () => 'users/',
serviserTerminal: (id) => `users/${id}/terminal/`,
// Radni nalozi
radniNalozi: (params = {}) => {
const baseUrl = 'operativa/radni-nalozi/';
const cleanParams = Object.fromEntries(
Object.entries(params).filter(([_, v]) => v != null)
);
const queryString = new URLSearchParams(cleanParams).toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
},
radniNalogDetalji: (id) => `operativa/radni-nalozi/${id}/`,
sljedeciBrojNaloga: () => 'operativa/radni-nalozi/sljedeci-broj/',
// Putni nalozi i logistika
putniNalozi: () => 'operativa/putni-nalozi/',
// Vozila i strojevi (Fleet modul)
vozila: () => 'fleet/vozila/',
strojevi: (vlasnikId = null) => {
const baseUrl = 'fleet/strojevi/';
if (vlasnikId) return `${baseUrl}?vlasnik=${vlasnikId}`;
return baseUrl;
},
strojDetalji: (id) => `fleet/strojevi/${id}/`,
// Kupci / Klijenti
kupciSvi: () => 'kupci/svi/',
kupacDetalji: (id) => `kupci/svi/${id}/`,
// Kalendar i raspored
mojRaspored: () => 'kalendar/moj-raspored/',
kalendarDogadaji: () => 'kalendar/dogadaji/'
};
// --- API METODE ---
/**
* 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(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({}, ssrToken)
});
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)
*/
export async function fetchRadniNalozi(params = {}) {
try {
// Logika čišćenja i slaganja query parametara je sada delegirana routes objektu!
const url = `${API_BASE}${routes.radniNalozi(params)}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
return await handleResponse(res);
} catch (e) {
console.error("fetchRadniNalozi Failure:", e);
return [];
}
}
/**
* Paralelni dohvat podataka za glavno dispečersko sučelje (Dashboard flote i naloga)
*/
export async function fetchDashboardData(params = {}) {
try {
// Dinamički povlačimo staze iz routes objekta za paralelno okidanje
const urlVozila = `${API_BASE}${routes.vozila()}`;
const urlNalozi = `${API_BASE}${routes.radniNalozi()}`;
const [resV, resN] = await Promise.all([
fetch(urlVozila, { method: 'GET', headers: getAuthHeaders() }),
fetch(urlNalozi, { method: 'GET', headers: getAuthHeaders() })
]);
// Budući da Dashboard podatke često renderiraš u paralelnim karticama,
// koristimo brzi i sigurni fallback u slučaju prazne baze ili neočekivanog formata
const vozilaData = resV.ok ? await resV.json().catch(() => []) : [];
const naloziData = resN.ok ? await resN.json().catch(() => []) : [];
return {
vozila: Array.isArray(vozilaData) ? vozilaData : (vozilaData.results || []),
nalozi: Array.isArray(naloziData) ? naloziData : (naloziData.results || [])
};
} catch (e) {
console.error("FetchDashboardData Failure:", e);
return { vozila: [], nalozi: [] };
}
}
/**
* Prijava korisnika i pohrana JWT tokena
*/
export async function login(email, password) {
try {
// Povlačenje centralizirane rute za token
const url = `${API_BASE}${routes.login()}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Neuspješna prijava");
localStorage.setItem('access_token', data.access);
localStorage.setItem('refresh_token', data.refresh);
return { success: true };
} catch (e) {
console.error("Login Error:", e);
return { success: false, error: e.message };
}
}
/**
* Odjava korisnika i čišćenje lokalne pohrane
*/
export function logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
if (typeof window !== 'undefined') window.location.href = '/login';
}
/**
* Dohvat detalja pojedinačnog kupca/klijenta
*/
export async function fetchKupacDetalji(id) {
try {
const url = `${API_BASE}${routes.kupacDetalji(id)}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
return await handleResponse(res);
} catch (e) {
console.error(`Greška u fetchKupacDetalji za ID ${id}:`, e);
return null;
}
}
/**
* Unos novog servisnog vozila u bazu flote
*/
export async function createVozilo(payload) {
try {
const url = `${API_BASE}${routes.vozila()}`;
const res = await fetch(url, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await handleResponse(res);
if (data && typeof window !== 'undefined') {
window.showToast?.("Vozilo uspješno uneseno!", "success");
}
return data;
} catch (e) {
console.error("Greška u createVozilo:", e);
return null;
}
}
/**
* Kreiranje novog radnog naloga (šalje se FormData zbog učitavanja slika s terena)
*/
export async function createNalog(formData) {
try {
// Koristimo bazičnu rutu bez parametara za POST zahtjev
const url = `${API_BASE}${routes.radniNalozi()}`;
const res = await fetch(url, {
method: 'POST',
headers: getAuthHeaders(formData), // Automatski izbacuje Content-Type za FormData
body: formData
});
return await handleResponse(res);
} catch (e) {
console.error("Greška u createNalog:", e);
return null;
}
}
/**
* Parcijalno ažuriranje radnog naloga (npr. promjena statusa, dodavanje opisa) preko centralizirane rute
*/
export async function patchNalog(id, data) {
try {
// Koristimo novu stazu za detalje naloga iz routes objekta
const url = `${API_BASE}${routes.radniNalogDetalji(id)}`;
const res = await fetch(url, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify(data)
});
return await handleResponse(res);
} catch (e) {
console.error(`Greška u patchNalog za ID ${id}:`, e);
return null;
}
}
/**
* Dohvat svih kalendarskih događaja (planirani servisi, atesti, tereni)
*/
export async function fetchCalendarEvents() {
try {
// Koristimo novu stazu iz routes objekta
const url = `${API_BASE}${routes.kalendarDogadaji()}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
// Centralizirana obrada odgovora s Toast error handlingom
const data = await handleResponse(res);
if (!data) return []; // Siguran fallback ako zahtjev baci grešku
return Array.isArray(data) ? data : (data.results || []);
} catch (e) {
console.error("fetchCalendarEvents Failure:", e);
return [];
}
}
/**
* Dohvat osobnog rasporeda servisera iz kalendara
*/
export async function fetchMojRaspored() {
try {
// Koristimo stazu iz routes objekta
const url = `${API_BASE}${routes.mojRaspored()}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
// Centralizirana obrada odgovora s Toast error handlingom
const data = await handleResponse(res);
if (!data) return []; // Siguran fallback na prazan niz ako zahtjev ne prođe
return Array.isArray(data) ? data : (data.results || []);
} catch (e) {
console.error("fetchMojRaspored Failure:", e);
return [];
}
}
/**
* Dohvat popisa svih kupaca/klijenata za dropdown u novom nalogu
*/
export async function fetchKupciData() {
try {
// Koristimo stazu iz routes objekta
const url = `${API_BASE}${routes.kupciSvi()}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
// Centralizirana obrada odgovora
const data = await handleResponse(res);
if (!data) return { kupci: [] }; // Fallback ako je token nevažeći
// Vraćamo objekt u formatu koji novi.astro destrukturira na serveru
return {
kupci: Array.isArray(data) ? data : (data.results || [])
};
} catch (e) {
console.error("fetchKupciData Failure:", e);
return { kupci: [] };
}
}
/**
* Dohvat popisa strojeva (opcionalno filtrirano po vlasniku) pomoću centralizirane rute
*/
export async function fetchStrojeviData(vlasnikId = null) {
try {
// Generiramo ispravan URL preko routes objekta
const url = `${API_BASE}${routes.strojevi(vlasnikId)}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
// Koristimo centraliziranu obradu odgovora (s Toast podrškom)
const data = await handleResponse(res);
if (!data) return { strojevi: [] }; // Siguran fallback ako je zahtjev prekinut (401, 500...)
// Vraćamo objekt sa strojevima prateći strukturu koju novi.astro očekuje (.map destructuring)
return {
strojevi: Array.isArray(data) ? data : (data.results || [])
};
} catch (e) {
console.error("fetchStrojeviData Failure:", e);
return { strojevi: [] };
}
}
/**
* Dohvat popisa svih putnih naloga pomoću centralizirane rute i handleResponse-a
*/
export async function fetchPutniNaloziData() {
try {
// Koristimo zajedničku stazu iz routes objekta
const url = `${API_BASE}${routes.putniNalozi()}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders()
});
// Koristimo centraliziranu obradu odgovora (s Toast error handlingom)
const data = await handleResponse(res);
if (!data) return []; // Fallback ako je handleResponse presreo grešku i vratio null
// Provjera vraća li Django čistu listu ili paginirani rezultatski objekt (results)
return Array.isArray(data) ? data : (data.results || []);
} catch (e) {
console.error("Greška pri dohvatu putnih naloga:", e);
return [];
}
}
/**
* Dohvaća sljedeći slobodni broj radnog naloga s backenda
* Osigurano protiv duplih kosih crta i preflight redirecta
*/
export async function fetchSljedeciBrojNaloga() {
try {
// Pametno spajanje baze i rute (isto kao u ostalim očišćenim metodama)
const base = API_BASE.endsWith('/') ? API_BASE : `${API_BASE}/`;
const ruta = routes.sljedeciBrojNaloga();
const url = `${base}${ruta}`;
const res = await fetch(url, {
method: 'GET',
headers: getAuthHeaders() // Koristi centralizirane headere umjesto sirovog objekta
});
if (res.ok) {
return await res.json();
} else {
console.error("Greška na backendu pri dohvaćanju brojača:", res.status);
return { broj_naloga: "RN-2026-XXXX" };
}
} catch (err) {
console.error("Mrežna greška u fetchSljedeciBrojNaloga:", err);
return { broj_naloga: "RN-2026-XXXX" };
}
}
/**
* Kreiranje novog putnog naloga - S koso crtom (trailing slash) za Django usklađenost
*/
export async function createPutniNalog(radniNalogId, voziloId) {
try {
// Koristimo novu stazu iz routes objekta
const url = `${API_BASE}${routes.putniNalozi()}`;
const res = await fetch(url, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
radni_nalog_id: parseInt(radniNalogId, 10),
vozilo: parseInt(voziloId, 10)
})
});
// Vraća gotov JSON objekt (ili null ako je toast okinuo grešku)
return await handleResponse(res);
} catch (e) {
console.error("createPutniNalog krah:", e);
return null;
}
}
// NOVO (ZADRŽI)
/**
* Dohvaća podatke o trenutno ulogiranom korisniku iz Django baze podataka.
* Podržava prosljeđivanje eksplicitnog tokena radi sprječavanja race conditiona.
*/
export async function getTrenutniKorisnik(eksplicitanToken = null, params = {}) {
const url = getApiUrl('auth.me', params);
if (!url) return null;
const options = {
method: 'GET',
// 🚀 GENIJALNO USKLAĐIVANJE: Proslijeđeni token guramo ravno kao drugi parametar tvoje funkcije!
// Tvoj getAuthHeaders će ga prepoznati kao primarni token i potpuno preskočiti sessionStorage.
headers: getAuthHeaders(null, eksplicitanToken)
};
const res = await fetch(url, options);
const ishod = await handleResponse(res, { url, options });
return ishod || null;
}

View File

@@ -0,0 +1,18 @@
// src/lib/nav.js
/**
* Pretvara relativnu stazu iz JSON-a u siguran i dinamički URL.
* Ako Astro ima konfiguriran base URL (npr. u cloudu), ova funkcija se brine o tome.
* @param {string} url - Sirovi URL iz konfiguracije (npr. "/operativa/radni-nalozi")
* @returns {string}
*/
export function getBaseUrl(url) {
if (!url) return '/';
// Astro automatski osigurava import.meta.env.BASE_URL ovisno o okruženju
const baseUrl = import.meta.env.BASE_URL || '/';
// Čistimo duple kose crte (slashes) radi urednosti routera
const spojeniUrl = `${baseUrl}/${url}`.replace(/\/+/g, '/');
return spojeniUrl;
}

View File

@@ -0,0 +1,163 @@
---
// src/pages/fleet/strojevi/[id].astro
import Layout from "../../../layouts/Layout.astro";
import Button from "../../../components/Button.astro";
import AkcijePanel from "../../../components/AkcijePanel.astro";
// Importiranje utilitija
const { id } = Astro.params;
const API_BASE = import.meta.env.PUBLIC_API_URL;
let stroj = null;
try {
// Dohvaćamo točno određeni stroj
const res = await fetch(`${API_BASE}/fleet/strojevi/${id}/?t=${Date.now()}`);
if (res.ok) stroj = await res.json();
} catch (e) {
console.error("Greška pri dohvatu detalja stroja:", e);
}
if (!stroj) return Astro.redirect("/404");
// Logika za Atest status (npr. ako je unutar 30 dana ili prošao)
const atestDate = stroj.datum_zadnjeg_atesta ? new Date(stroj.datum_zadnjeg_atesta) : null;
const isAtestExpired = atestDate && atestDate < new Date();
---
<Layout title={`Stroj | ${stroj.naziv}`}>
<div class="w-full space-y-10">
<header class="px-2 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div class="space-y-4">
<a href="/fleet/strojevi" class="text-blue-600 text-[10px] font-black uppercase tracking-[0.3em] hover:underline flex items-center gap-2 no-underline mb-4">
<i class="fa-solid fa-arrow-left text-[8px]"></i> Povratak u park
</a>
<div class="flex items-center gap-4">
<h1 class="text-4xl sm:text-6xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none italic">
{stroj.naziv}
</h1>
<div class="hidden sm:block px-4 py-1.5 bg-blue-600 text-white rounded-full text-[10px] font-black uppercase tracking-widest italic">
{stroj.tip_human_readable}
</div>
</div>
<p class="text-gray-500 dark:text-gray-400 font-medium italic">
Vlasnik: <a href={`/kupci/${stroj.vlasnik}`} class="text-blue-600 underline font-bold">{stroj.vlasnik_naziv}</a>
</p>
</div>
<div class="flex items-center gap-3 px-6 py-4 bg-white dark:bg-gray-800 rounded-[2rem] border border-gray-100 dark:border-gray-700 shadow-xl">
<div class="flex flex-col items-end">
<span class="text-[8px] font-black uppercase text-gray-400 tracking-widest">Serijski broj</span>
<span class="text-xl font-black text-gray-900 dark:text-white tracking-tighter italic">{stroj.serijski_broj}</span>
</div>
<div class="w-px h-8 bg-gray-100 dark:bg-gray-700 mx-2"></div>
<i class="fa-solid fa-barcode text-gray-200 text-2xl"></i>
</div>
</header>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 px-2">
<div class="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden group">
<i class="fa-solid fa-calendar absolute -right-4 -bottom-4 text-6xl opacity-5 group-hover:rotate-12 transition-transform"></i>
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest">Godište</span>
<span class="text-4xl font-black dark:text-white italic tracking-tighter">{stroj.godina_proizvodnje || 'N/A'}</span>
</div>
<div class="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden group">
<i class="fa-solid fa-gauge-high absolute -right-4 -bottom-4 text-6xl opacity-5 group-hover:rotate-12 transition-transform"></i>
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest">Radni sati</span>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-black dark:text-white italic tracking-tighter">{parseFloat(stroj.radni_sati).toLocaleString('hr-HR')}</span>
<span class="text-xs font-black text-blue-600 uppercase">H</span>
</div>
</div>
<div class={`p-8 rounded-[2.5rem] border relative overflow-hidden group ${isAtestExpired ? 'bg-red-50 border-red-100 dark:bg-red-900/10 dark:border-red-900/20' : 'bg-emerald-50 border-emerald-100 dark:bg-emerald-900/10 dark:border-emerald-900/20'}`}>
<i class="fa-solid fa-shield-check absolute -right-4 -bottom-4 text-6xl opacity-10 group-hover:rotate-12 transition-transform"></i>
<span class={`text-[9px] font-black uppercase block mb-2 tracking-widest ${isAtestExpired ? 'text-red-600' : 'text-emerald-600'}`}>Zadnji atest</span>
<span class={`text-2xl font-black italic tracking-tighter ${isAtestExpired ? 'text-red-600' : 'text-emerald-600'}`}>
{stroj.datum_zadnjeg_atesta ? new Date(stroj.datum_zadnjeg_atesta).toLocaleDateString('hr-HR') : 'Nema podataka'}
</span>
</div>
<div class="bg-gray-900 dark:bg-black p-8 rounded-[2.5rem] text-white relative overflow-hidden group shadow-2xl">
<i class="fa-solid fa-industry absolute -right-4 -bottom-4 text-6xl opacity-20 group-hover:rotate-12 transition-transform"></i>
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest">Proizvođač</span>
<span class="text-2xl font-black italic tracking-tighter block uppercase">{stroj.marka}</span>
</div>
</div>
<div class="grid md:grid-cols-10 gap-8 items-start px-2">
<div class="md:col-span-7 space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-[3.5rem] p-10 md:p-16 border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 relative overflow-hidden">
<div class="grid sm:grid-cols-2 gap-12 items-center">
<div class="space-y-8">
<div>
<label class="text-[10px] font-black uppercase tracking-[0.3em] text-blue-600 block mb-4 italic">Tehnička Specifikacija</label>
<div class="space-y-4">
<div class="flex justify-between border-b border-gray-50 dark:border-gray-700 pb-2">
<span class="text-xs font-bold text-gray-400 uppercase italic">Model</span>
<span class="text-sm font-black dark:text-white uppercase">{stroj.model_stroja}</span>
</div>
<div class="flex justify-between border-b border-gray-50 dark:border-gray-700 pb-2">
<span class="text-xs font-bold text-gray-400 uppercase italic">Tip jedinice</span>
<span class="text-sm font-black dark:text-white uppercase">{stroj.tip_human_readable}</span>
</div>
<div class="flex justify-between border-b border-gray-50 dark:border-gray-700 pb-2">
<span class="text-xs font-bold text-gray-400 uppercase italic">Registracija</span>
<span class="text-sm font-black dark:text-white uppercase">{stroj.registracija || 'NIJE REGISTRIRAN'}</span>
</div>
</div>
</div>
<div class="pt-6">
<Button variant="primary" class="!rounded-2xl py-4 w-full justify-center font-black uppercase italic tracking-widest">
<i class="fa-solid fa-wrench mr-2"></i> Otvori servisni nalog
</Button>
</div>
</div>
<div class="relative group">
<div class="absolute inset-0 bg-blue-600 rounded-[2.5rem] rotate-3 opacity-10 group-hover:rotate-0 transition-transform duration-500"></div>
<div class="relative aspect-square bg-gray-100 dark:bg-gray-900 rounded-[2.5rem] overflow-hidden border border-gray-100 dark:border-gray-700">
<img
src="http://googleusercontent.com/image_collection/image_retrieval/5305205557646734227"
alt={stroj.naziv}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 grayscale hover:grayscale-0"
/>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-[2.5rem] p-10 border border-dashed border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-black uppercase tracking-[0.2em] mb-4 dark:text-white italic">Status održavanja</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed italic">
Ovaj stroj ({stroj.naziv}) je zadnji put atestiran {stroj.datum_zadnjeg_atesta}. Prema servisnom planu, toranjske dizalice zahtijevaju periodični pregled svakih 12 mjeseci. Trenutni radni sati ({stroj.radni_sati} h) sugeriraju potrebu za redovnim podmazivanjem sklopova.
</p>
</div>
</div>
<div class="md:col-span-3">
<AkcijePanel tip="stroj" podaci={stroj} />
<!-- <div class="mt-8 bg-blue-600 rounded-[2.5rem] p-8 text-white shadow-2xl relative overflow-hidden group">
<i class="fa-solid fa-file-pdf absolute -right-4 -top-4 text-7xl opacity-10"></i>
<h4 class="text-sm font-black uppercase tracking-widest italic mb-4">Dokumentacija</h4>
<div class="space-y-3 relative z-10">
<button class="w-full text-left bg-white/10 hover:bg-white/20 p-4 rounded-xl border border-white/10 transition-all">
<span class="text-[9px] font-black uppercase block opacity-70">Atestni list</span>
<span class="text-xs font-bold italic">Preuzmi PDF</span>
</button>
<button class="w-full text-left bg-white/10 hover:bg-white/20 p-4 rounded-xl border border-white/10 transition-all">
<span class="text-[9px] font-black uppercase block opacity-70">Tehnički podaci</span>
<span class="text-xs font-bold italic">Otvori specifikaciju</span>
</button>
</div>
</div> -->
</div>
</div>
</div>
</Layout>

View File

@@ -0,0 +1,101 @@
---
// src/pages/fleet/strojevi/index.astro
import Layout from "../../../layouts/Layout.astro";
import AkcijePanel from "../../../components/AkcijePanel.astro";
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
import NaslovList from "../../../components/NaslovList.astro";
import GenericKarticaItem from "../../../components/GenericKarticaItem.astro";
// Importiranje utilitija
import { fetchStrojeviData, fetchCurrentUser } from "../../../lib/api";
import { getStatusColorClass, formatStatus } from "../../../utils/ui";
// 1. DOHVAT FILTERA IZ URL-a
const url = new URL(Astro.request.url);
const vlasnikId = url.searchParams.get('vlasnik');
// 2. DOHVAT PODATAKA (Paralelno)
const [{ strojevi }, user] = await Promise.all([
fetchStrojeviData(vlasnikId),
fetchCurrentUser()
]);
// 3. LOGIKA ZA DINAMIČKE INFORMACIJE
const imeKupca = strojevi.length > 0 && vlasnikId ? strojevi[0].vlasnik_naziv : null;
const imeKorisnika = user?.first_name || "Serviser";
const ulogaKorisnika = user?.is_serviser ? "Tehnička Služba" : "Logistika";
let dinamickiPodnaslov = "Pregled kompletne strojne flote i dizalica";
if (vlasnikId && imeKupca) {
dinamickiPodnaslov = `Prikaz strojeva dodijeljenih klijentu: ${imeKupca}`;
}
---
<Layout title="Strojni park | Upravljanje">
<div class="w-full space-y-10">
<!-- 1. WELCOME HEADER -->
<WelcomeHeader
textH1="Strojni park"
ime={imeKorisnika}
uloga={ulogaKorisnika}
podnaslov={dinamickiPodnaslov}
/>
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
<div class="md:col-span-7 space-y-6">
<!-- 2. NASLOV LISTE S GUMBOM ZA RESET FILTRA -->
<div class="space-y-2">
<NaslovList
naslov={vlasnikId ? "Filtrirani strojevi" : "Tehničke jedinice"}
ukupno={strojevi.length}
label1="Ukupno"
count1={strojevi.length}
label2="Na terenu"
count2={strojevi.length} // Ovdje možeš dodati filter za npr. 'u_radu'
/>
{vlasnikId && (
<div class="px-6">
<a href="/fleet/strojevi" class="text-[9px] font-black text-red-500 uppercase tracking-widest hover:text-red-600 transition-colors flex items-center gap-2 italic">
<i class="fa-solid fa-circle-xmark"></i> Ukloni filter klijenta
</a>
</div>
)}
</div>
<!-- 3. GENERIČKA LISTA STROJEVA -->
<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="strojevi-list">
{strojevi.length > 0 ? strojevi.map((s) => (
<GenericKarticaItem
bojaTeme="indigo"
href={`/fleet/strojevi/${s.id}`}
ikona="fa-screwdriver-wrench"
naslov={s.naziv}
subNaslov={s.serijski_broj}
metaTekst={`${s.vlasnik_naziv} | Radni sati: ${s.radni_sati}h`}
status={s.status || 'planirano'}
statusPrikaz={formatStatus(s.status || 'planirano')}
statusBojaClass={getStatusColorClass(s.status || 'planirano')}
/>
)) : (
<div class="p-24 text-center">
<i class="fa-solid fa-box-open text-4xl text-gray-100 dark:text-gray-700 mb-4 block"></i>
<p class="italic text-gray-400 text-[10px] uppercase tracking-widest leading-none">Nema strojeva u bazi podataka</p>
</div>
)}
</div>
</div>
</div>
<!-- DESNI PANEL -->
<div class="md:col-span-3">
<AkcijePanel tip="fleet" />
</div>
</div>
</div>
</Layout>

View File

@@ -0,0 +1,228 @@
// src/pages/fleet/vozila/[id].astro
---
import Layout from "../../../layouts/Layout.astro";
import Button from "../../../components/Button.astro"; // Uvoz tvoje zajedničke komponente za gumbe
import { getStatusColorClass } from "../../../utils/ui";
// 1. Dohvat ID-ja iz URL parametara
const { id } = Astro.params;
let vozilo = null;
let radniNalozi = [];
// 2. Dohvat podataka s Django API-ja (Backend)
try {
const voziloResponse = await fetch(`http://127.0.0.1:8000/api/fleet/vozila/${id}/`);
if (voziloResponse.ok) {
vozilo = await voziloResponse.json();
}
const naloziResponse = await fetch(`http://127.0.0.1:8000/api/operativa/radni-nalozi/`);
if (naloziResponse.ok) {
const sviNalozi = await naloziResponse.json();
radniNalozi = sviNalozi.filter((nalog) => nalog.vozilo === parseInt(id) || nalog.vozilo_id === parseInt(id));
}
} catch (error) {
console.error("Greška pri dohvaćanju podataka s Django API-ja:", error);
}
if (!vozilo) {
return Astro.redirect("/fleet/vozila?error=not-found");
}
function formatStatusLocal(status) {
const map = {
'aktivan': 'Aktivan',
'servis': 'Na servisu',
'neaktivan': 'Izvan pogona'
};
return map[status?.toLowerCase()] || status;
}
function getPrioritetClass(prioritet) {
const p = prioritet?.toLowerCase();
if (p === 'hitan' || p === 'visok') return 'bg-red-500/10 text-red-500 border-red-500/20';
if (p === 'srednji') return 'bg-amber-500/10 text-amber-500 border-amber-500/20';
return 'bg-blue-500/10 text-blue-500 border-blue-500/20';
}
const statusKlasa = getStatusColorClass(vozilo.status?.toLowerCase());
---
<Layout title={`Vozilo | ${vozilo.naziv}`}>
<div class="max-w-4xl mx-auto px-4 py-8 font-sans">
<div class="mb-6">
<Button
href="/fleet/vozila"
variant="ghost"
class="!px-0 text-sm font-black uppercase tracking-widest text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<i class="fa-solid fa-arrow-left-long mr-2"></i> Povratak na popis
</Button>
</div>
<div class="w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-2xl rounded-[3rem] border border-gray-100 dark:border-gray-700 overflow-hidden transition-all mb-8">
<div class="flex flex-wrap justify-between items-center bg-gray-50/50 dark:bg-gray-900/50 px-8 py-8 border-b border-gray-100 dark:border-gray-700 gap-4">
<div class="flex items-center gap-5">
<div class="bg-blue-600/10 text-blue-600 dark:text-blue-400 p-5 rounded-2xl shadow-md">
<i class="fa-solid fa-truck text-3xl"></i>
</div>
<div>
<h1 class="text-3xl md:text-4xl font-black uppercase tracking-tighter italic leading-none">
{vozilo.naziv}
</h1>
<span class="text-sm font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest block mt-1">
Interni ID: #{vozilo.id}
</span>
</div>
</div>
<span class={`text-xs font-black px-4 py-2 rounded-full uppercase tracking-widest border shadow-sm ${statusKlasa}`}>
{vozilo.status_prikaz || formatStatusLocal(vozilo.status)}
</span>
</div>
<div class="p-8 grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-gray-50 dark:bg-gray-900/40 p-6 rounded-2xl border border-gray-100 dark:border-gray-700/50">
<span class="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500 block mb-1">
Registracijska oznaka
</span>
<div class="flex items-center gap-3">
<i class="fa-solid fa-id-card text-xl text-blue-600 dark:text-blue-400 opacity-70"></i>
<span class="text-2xl font-black tracking-tight uppercase italic text-gray-800 dark:text-gray-100">
{vozilo.registracija}
</span>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900/40 p-6 rounded-2xl border border-gray-100 dark:border-gray-700/50">
<span class="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500 block mb-1">
Trenutna kilometraža
</span>
<div class="flex items-center gap-3">
<i class="fa-solid fa-gauge text-xl text-blue-600 dark:text-blue-400 opacity-70"></i>
<span class="text-2xl font-black tracking-tight text-gray-800 dark:text-gray-100">
{vozilo.trenutni_kilometri?.toLocaleString('hr-HR') || 0} <span class="text-sm font-bold text-gray-400 uppercase">KM</span>
</span>
</div>
</div>
<div class="md:col-span-2 bg-blue-600 text-white p-6 rounded-2xl shadow-xl relative overflow-hidden group">
<div class="absolute right-6 bottom-2 opacity-10 text-9xl font-black pointer-events-none transition-transform group-hover:scale-110 duration-500">
<i class="fa-solid fa-route"></i>
</div>
<span class="text-[10px] font-black uppercase tracking-[0.2em] text-white/60 block mb-1">
Trenutni raspored / Baza
</span>
<h3 class="text-xl font-black uppercase tracking-tight italic">
Terenska Baza
</h3>
<p class="text-xs text-white/80 font-medium mt-1 max-w-xl">
Vozilo je mapirano na centralni logistički sustav fleet managementa. Sve izmjene kilometara i servisnih naloga sinkroniziraju se u realnom vremenu s radnim nalozima operativnog tima.
</p>
</div>
</div>
<div class="px-8 py-5 bg-gray-50 dark:bg-gray-900/30 border-t border-gray-100 dark:border-gray-700 flex justify-end gap-3">
<Button
href={`/operativa/radni-nalozi?vozilo=${vozilo.id}`}
variant="secondary"
class="text-xs font-black uppercase tracking-widest rounded-xl shadow-sm"
>
<i class="fa-solid fa-file-invoice mr-1.5 opacity-70"></i> Otvori u operativi
</Button>
<Button
id="btn-brzi-servis"
data-id={vozilo.id}
variant="primary"
class="text-xs font-black uppercase tracking-widest rounded-xl shadow-lg shadow-blue-600/20"
>
<i class="fa-solid fa-screwdriver-wrench mr-1.5"></i> Otvori Servis
</Button>
</div>
</div>
<div class="w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-2xl rounded-[3rem] border border-gray-100 dark:border-gray-700 overflow-hidden p-8">
<div class="flex items-center justify-between mb-6 border-b border-gray-100 dark:border-gray-700 pb-4">
<div class="flex items-center gap-3">
<i class="fa-solid fa-clipboard-list text-2xl text-blue-600 dark:text-blue-400"></i>
<h2 class="text-xl font-black uppercase tracking-tight italic">Povijest Radnih Naloga</h2>
</div>
<span class="text-xs font-black bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded-xl uppercase tracking-widest border border-gray-200 dark:border-gray-700">
Ukupno: {radniNalozi.length}
</span>
</div>
{radniNalozi.length === 0 ? (
<div class="text-center py-12 bg-gray-50 dark:bg-gray-900/20 rounded-2xl border border-dashed border-gray-200 dark:border-gray-700">
<i class="fa-solid fa-folder-open text-4xl text-gray-300 dark:text-gray-600 mb-3"></i>
<p class="text-sm font-bold text-gray-400 uppercase tracking-wider">Nema evidentiranih radnih naloga za ovo vozilo.</p>
</div>
) : (
<div class="space-y-4">
{radniNalozi.map((nalog) => (
<div class="flex flex-wrap items-center justify-between p-5 bg-gray-50 dark:bg-gray-900/30 rounded-2xl border border-gray-100 dark:border-gray-700/50 hover:border-blue-600 dark:hover:border-blue-400 transition-all gap-4">
<div class="flex items-start gap-4">
<div class="bg-gray-200 dark:bg-gray-800 text-gray-700 dark:text-gray-300 p-3 rounded-xl font-black text-xs">
#{nalog.broj_naloga || nalog.id}
</div>
<div>
<h4 class="text-sm font-black uppercase tracking-tight text-gray-800 dark:text-gray-100">
{nalog.opis_kvara || nalog.naslov || "Opis radova nije definiran"}
</h4>
<div class="flex items-center gap-4 mt-1 text-xs text-gray-400 font-medium">
<span>
<i class="fa-solid fa-calendar-days mr-1 text-blue-600/60"></i>
{nalog.datum_otvaranja ? new Date(nalog.datum_otvaranja).toLocaleDateString('hr-HR') : "Nepoznat datum"}
</span>
{nalog.kilometraža_prijave && (
<span>
<i class="fa-solid fa-gauge-high mr-1 text-blue-600/60"></i>
{nalog.kilometraža_prijave.toLocaleString('hr-HR')} KM
</span>
)}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<span class={`text-[10px] font-black px-2.5 py-1 rounded-lg border uppercase tracking-wider ${getPrioritetClass(nalog.prioritet)}`}>
{nalog.prioritet || "Normalno"}
</span>
<Button
href={`/operativa/radni-nalozi/${nalog.id}`}
variant="ghost"
class="!p-2.5 bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-300 rounded-xl border border-gray-200 dark:border-gray-600 hover:text-blue-600 dark:hover:text-blue-400 transition-colors shadow-sm"
title="Pregledaj nalog"
>
<i class="fa-solid fa-chevron-right"></i>
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</Layout>
<script>
function initDetaljiVozila() {
const servisBtn = document.getElementById('btn-brzi-servis');
if (servisBtn) {
servisBtn.addEventListener('click', () => {
const voziloId = servisBtn.getAttribute('data-id');
console.log(`Otvaram brzu servisnu prijavu za vozilo ID: ${voziloId}`);
window.location.href = `/operativa/radni-nalozi/novo?vozilo_id=${voziloId}&tip=servis`;
});
}
}
initDetaljiVozila();
document.addEventListener('astro:after-swap', initDetaljiVozila);
</script>

View File

@@ -0,0 +1,107 @@
---
// src/pages/fleet/vozila/index.astro
import Layout from "../../../layouts/Layout.astro";
import Button from "../../../components/Button.astro";
import AkcijePanel from "../../../components/AkcijePanel.astro";
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
import NaslovList from "../../../components/NaslovList.astro";
import GenericKarticaItem from "../../../components/GenericKarticaItem.astro";
// Importiranje utilitija i centraliziranog API-ja
import { fetchDashboardData, fetchCurrentUser } from "../../../lib/api";
import { getStatusColorClass, formatStatus } from "../../../utils/ui";
// 1. DOHVAT FILTERA IZ URL-a (npr. ?status=servis)
const statusFilter = Astro.url.searchParams.get('status')?.toLowerCase();
// 2. DOHVAT PODATAKA
const [dashboardData, user] = await Promise.all([
fetchDashboardData(),
fetchCurrentUser()
]);
const vozila = dashboardData?.vozila || [];
// 3. LOGIKA STATISTIKE (Uvijek se računa iz originalnog niza, neovisno o filteru)
const aktivnaVozila = vozila.filter(v => v.status?.toLowerCase() === 'aktivan').length;
const naServisu = vozila.filter(v => v.status?.toLowerCase() === 'servis').length;
const neaktivnaVozila = vozila.filter(v => v.status?.toLowerCase() === 'neaktivan').length;
// 4. LOGIKA FILTRIRANJA (Serverska strana)
let filtriraniVozila = vozila;
if (statusFilter) {
filtriraniVozila = vozila.filter(v => v.status?.toLowerCase() === statusFilter);
}
// Možeš dodati limit kroz props ako ovu stranicu ikada budeš koristio kao parcijalnu komponentu
const limit = 0;
const prikazanaVozila = limit > 0 ? filtriraniVozila.slice(0, limit) : filtriraniVozila;
// Dinamički naslov liste ovisno o odabranom filteru flote
const prikazaniNaslov = statusFilter
? `Flota: ${formatStatus(statusFilter)}`
: "Aktivna Flota";
---
<Layout title="Vozni park | Flota">
<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">
<NaslovList
naslov={prikazaniNaslov}
ukupno={filtriraniVozila.length}
label1="Spremni"
filter1="aktivan" count1={aktivnaVozila}
label2="Servis"
filter2="servis" count2={naServisu}
label3="Neaktivni"
filter3="neaktivan" count3={neaktivnaVozila}
/>
<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="vozila-list">
{prikazanaVozila.length > 0 ? prikazanaVozila.map((v) => (
<GenericKarticaItem
href={`/fleet/vozila/${v.id}`}
status={v.status?.toLowerCase()}
statusBojaClass={getStatusColorClass(v.status?.toLowerCase())}
ikona={v.status?.toLowerCase() === 'aktivan' ? 'fa-truck' : 'fa-screwdriver-wrench'}
naslov={v.naziv}
subNaslov={v.registracija}
metaTekst={`Prijavljeno: ${v.trenutni_kilometri?.toLocaleString() || 0} KM | Lokacija: ${v.lokacija || v.trenutna_lokacija || 'Baza (Zagreb)'}`}
statusPrikaz={v.status_prikaz || formatStatus(v.status?.toLowerCase())}
/>
)) : (
<div class="p-24 text-center">
<i class="fa-solid fa-truck-dash text-4xl text-gray-100 dark:text-gray-700 mb-4 block"></i>
<p class="italic text-gray-400 text-[10px] uppercase tracking-widest leading-none">Nema vozila za odabrani status</p>
{!statusFilter && (
<Button variant="outline" class="mt-6" id="btn-dodaj-prvo">Dodaj prvo vozilo</Button>
)}
</div>
)}
</div>
</div>
</div>
<div class="md:col-span-3">
<AkcijePanel />
</div>
</div>
</div>
</Layout>
<script>
// Inicijalizacija klikova na kartice i filtere unutar flote
import { initFilters } from "../../../scripts/filters";
initFilters();
document.addEventListener('astro:after-swap', initFilters);
</script>

View File

@@ -0,0 +1,100 @@
---
// src/pages/index.astro
import Layout from "../layouts/Layout.astro";
import WelcomeHeader from "../components/WelcomeHeader.astro";
import AkcijePanel from "../components/AkcijePanel.astro";
import RadniNalogLista from "../components/RadniNalogLista.astro";
import StatsGrid from "../components/StatsGrid.astro";
import Kalendar from "../components/Kalendar/Kalendar.astro";
import Brojac from '../components/Brojac.jsx';
// API i Centralizirani Utils iz api.js
import { fetchDashboardData } from "../lib/api";
// 1. Dohvat podataka s backenda (Samo bazični dashboard podaci koji se mogu poslužiti asinkrono)
const data = await fetchDashboardData();
const nalozi = data?.nalozi || [];
// 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;
const zavrseniNalozi = nalozi.filter(n => {
const statusMalo = n.status?.toLowerCase();
return statusMalo === 'zavrseno' || statusMalo === 'naplaceno';
}).length;
---
<Layout title="Dashboard | ServisLog">
<div class="w-full space-y-5">
<Brojac client:load />
<WelcomeHeader naslov="Kontrolna ploča" />
<div class="grid grid-cols-3 gap-2 sm:gap-6 px-2">
<StatsGrid
label="Planirano"
value={planiraniNalozi}
icon="fa-calendar-check"
variant="blue"
filterValue="planirano"
/>
<StatsGrid
label="U radu"
value={naloziURadu}
icon="fa-screwdriver-wrench"
variant="yellow"
animate={true}
filterValue="u_radu"
/>
<StatsGrid
label="Gotovo"
value={zavrseniNalozi}
icon="fa-circle-check"
variant="emerald"
filterValue="zavrseno"
/>
</div>
<div class="grid md:grid-cols-10 gap-8 items-start px-2 pb-10">
<div class="md:col-span-7 space-y-8">
<RadniNalogLista
limit={5}
naslov="Zadnje aktivnosti"
prikaziNaslov={true}
/>
</div>
<div class="md:col-span-3 sticky top-28">
<AkcijePanel tip="dashboard" />
</div>
</div>
</div>
<div class="mt-5">
<Kalendar />
</div>
</Layout>
<script>
import { initFilters } from "../scripts/filters.js";
import { provjeriPristupStranici } from "../utils/auth.js";
const pokreniInicijalizaciju = () => {
// 1. Pokrećemo sigurnosni guard
provjeriPristupStranici();
// 2. Ponovno aktiviramo klijentsko filtriranje tablica i kartica
initFilters();
};
// Pokrećemo odmah pri prvom učitavanju (First Load)
pokreniInicijalizaciju();
// 🚀 PODRŠKA ZA VIEW TRANSITIONS: Pokrećemo ponovno nakon svakog SPA prijelaza stranice
document.addEventListener('astro:page-load', pokreniInicijalizaciju);
</script>

View File

@@ -0,0 +1,33 @@
---
// src/pages/kalendar-dogadaja.astro
import Layout from "../layouts/Layout.astro";
import WelcomeHeader from "../components/WelcomeHeader.astro";
import Kalendar from "../components/Kalendar/Kalendar.astro";
---
<Layout title="Kalendar | ServisLog">
<div class="w-full space-y-10">
<WelcomeHeader naslov="Kalendar događaja" />
<div class="grid grid-cols-1 gap-2 sm:gap-6 px-2">
<Kalendar />
</div>
</div>
</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,131 @@
---
// src/pages/kupci/[id].astro
import Layout from "../../layouts/Layout.astro";
import AkcijePanel from "../../components/AkcijePanel.astro";
import ListaStrojeva from "../../components/ListaStrojeva.astro";
import { fetchStrojeviData, fetchRadniNalozi, fetchKupacDetalji, routes } from "../../lib/api";
const { id } = Astro.params;
// 1. Paralelno dohvaćanje podataka (brže renderiranje)
// Koristimo status 'u_radu' jer smo utvrdili da Django ne prepoznaje 'otvoreno'
const [strojeviData, kupac, nalozi] = await Promise.all([
fetchStrojeviData(id),
fetchKupacDetalji(id),
fetchRadniNalozi({ klijent: id, status: 'u_radu' })
]);
const strojevi = strojeviData?.strojevi || [];
// 2. Sigurnosni redirect ako kupac ne postoji
if (!kupac) return Astro.redirect("/404");
---
<Layout title={`Klijent | ${kupac.naziv}`}>
<div class="w-full space-y-10">
<!-- 1. HEADER (Brutalistički stil) -->
<header class="px-2 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div class="space-y-4">
<a href="/kupci/svi" class="text-indigo-600 text-[10px] font-black uppercase tracking-[0.3em] hover:underline flex items-center gap-2 no-underline mb-4">
<i class="fa-solid fa-arrow-left text-[8px]"></i> Povratak klijentima
</a>
<div class="flex items-center gap-4">
<h1 class="text-4xl sm:text-6xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none italic">
{kupac.naziv}
</h1>
<div class="hidden sm:block px-4 py-1.5 bg-indigo-600 text-white rounded-full text-[10px] font-black uppercase tracking-widest italic">
{kupac.tip || 'Partner'}
</div>
</div>
<p class="text-gray-500 dark:text-gray-400 font-medium italic">
Glavno sjedište: <span class="text-indigo-600 font-bold">{kupac.grad}, {kupac.adresa}</span>
</p>
</div>
<!-- OIB Info box -->
<div class="flex items-center gap-3 px-6 py-4 bg-white dark:bg-gray-800 rounded-[2rem] border border-gray-100 dark:border-gray-700 shadow-xl">
<div class="flex flex-col items-end">
<span class="text-[8px] font-black uppercase text-gray-400 tracking-widest">Porezni broj</span>
<span class="text-xl font-black text-gray-900 dark:text-white tracking-tighter italic">OIB: {kupac.oib}</span>
</div>
<div class="w-px h-8 bg-gray-100 dark:bg-gray-700 mx-2"></div>
<i class="fa-solid fa-address-card text-gray-200 text-2xl"></i>
</div>
</header>
<!-- 2. QUICK STATS (Brojači) -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 px-2">
<div class="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden group">
<i class="fa-solid fa-crane absolute -right-4 -bottom-4 text-6xl opacity-5 group-hover:rotate-12 transition-transform"></i>
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest italic">Strojni Park</span>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-black dark:text-white italic tracking-tighter">{strojevi.length}</span>
<span class="text-xs font-black text-indigo-600 uppercase">Jedinica</span>
</div>
</div>
<!-- Poveznica na filtriranu listu naloga -->
<a
href={routes.radniNalozi(id, 'u_radu')}
class="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden group hover:border-indigo-500 transition-all no-underline block"
>
<i class="fa-solid fa-file-invoice absolute -right-4 -bottom-4 text-6xl opacity-5 group-hover:rotate-12 transition-transform text-indigo-600"></i>
<span class="text-[9px] font-black uppercase text-gray-400 block mb-2 tracking-widest italic group-hover:text-indigo-600 transition-colors">
Otvoreni radni Nalozi
</span>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-black dark:text-white italic tracking-tighter">
{String(nalozi?.length || 0)}
</span>
<i class="fa-solid fa-arrow-up-right-from-square text-[10px] text-indigo-600 opacity-0 group-hover:opacity-100 transition-opacity ml-2"></i>
</div>
</a>
</div>
<!-- 3. MAIN CONTENT (Lista strojeva) -->
<div class="grid md:grid-cols-10 gap-8 items-start px-2">
<div class="md:col-span-7 space-y-12">
<div class="bg-white dark:bg-gray-800 rounded-[3.5rem] p-4 md:p-8 border border-gray-100 dark:border-gray-700 shadow-2xl shadow-indigo-500/5">
<ListaStrojeva
strojevi={strojevi}
naslov={`Operativna flota klijenta`}
prikaziVlasnika={false}
bojaTeme="indigo"
/>
</div>
<div class="px-8 py-10 bg-gray-50 dark:bg-gray-900/50 rounded-[2.5rem] border border-dashed border-gray-200 dark:border-gray-700 flex justify-between items-center group">
<div class="space-y-1">
<h3 class="text-sm font-black uppercase dark:text-white italic tracking-widest">Napredna analitika voznog parka</h3>
<p class="text-xs text-gray-500 italic">Pristupite detaljnim izvještajima i povijesti strojeva.</p>
</div>
<a href={`/fleet/strojevi?vlasnik=${id}`} class="bg-indigo-600 text-white p-4 rounded-2xl hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-500/20">
<i class="fa-solid fa-arrow-right-long text-xl"></i>
</a>
</div>
</div>
<!-- 4. RIGHT PANEL (Akcije i Detalji) -->
<div class="md:col-span-3">
<!-- AkcijePanel sada prima garantirani 'kupac' objekt -->
<AkcijePanel tip="vlasnik" podaci={kupac} />
<div class="mt-8 bg-indigo-600 rounded-[2.5rem] p-8 text-white shadow-2xl relative overflow-hidden group">
<i class="fa-solid fa-comments-dollar absolute -right-4 -top-4 text-7xl opacity-10"></i>
<h4 class="text-sm font-black uppercase tracking-widest italic mb-4">Naplata i Ugovori</h4>
<div class="space-y-3 relative z-10">
<button class="w-full text-left bg-white/10 hover:bg-white/20 p-4 rounded-xl border border-white/10 transition-all">
<span class="text-[9px] font-black uppercase block opacity-70">Uvjeti plaćanja</span>
<span class="text-xs font-bold italic">30 dana (valuta)</span>
</button>
<button class="w-full text-left bg-white/10 hover:bg-white/20 p-4 rounded-xl border border-white/10 transition-all">
<span class="text-[9px] font-black uppercase block opacity-70">Dokumentacija</span>
<span class="text-xs font-bold italic">Preuzmi PDF</span>
</button>
</div>
</div>
</div>
</div>
</div>
</Layout>

View File

@@ -0,0 +1,97 @@
---
// src/pages/kupci/svi.astro
import Layout from "../../layouts/Layout.astro";
import AkcijePanel from "../../components/AkcijePanel.astro";
import WelcomeHeader from "../../components/WelcomeHeader.astro";
// Importiranje nove funkcije
import { fetchKupciData } from "../../lib/api";
import { getStatusColorClass } from "../../utils/ui";
// 1. DOHVAT PODATAKA (Koristimo novu funkciju)
const { kupci } = await fetchKupciData();
// 2. LOGIKA STATISTIKE
const aktivniKupci = kupci.filter(k => k.aktivno === true).length;
const brojPravnih = kupci.filter(k => k.tip === 'pravno').length;
---
<Layout title="Partneri i Kupci | Servislog">
<div class="w-full space-y-10">
<WelcomeHeader textH1="Baza" textH1dodatno="Partnera" podnaslov="Pregled klijenata, kontakata i strojne flote" />
<div class="grid md:grid-cols-10 gap-8 items-start mb-10 px-2">
<div class="md:col-span-7 space-y-6">
<div class="flex justify-between items-center px-4">
<h2 class="text-xl font-black text-gray-900 dark:text-white uppercase tracking-tight italic">Popis klijenata</h2>
<div class="flex items-center gap-4">
<span class="text-[10px] font-bold uppercase text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20 px-2 py-1 rounded border border-emerald-200/50 italic">Aktivni: {aktivniKupci}</span>
<span class="text-[10px] font-bold uppercase text-blue-600 bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded border border-blue-200/50 italic">Tvrtke: {brojPravnih}</span>
</div>
</div>
<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="kupci-list">
{kupci.length > 0 ? kupci.map((k) => (
<a
href={`/kupci/${k.id}`}
class="kupac-item flex items-center justify-between p-8 hover:bg-blue-50/30 dark:hover:bg-gray-700/30 border-b border-gray-50 dark:border-gray-700 last:border-0 group transition-all"
>
<div class="flex items-center gap-6 text-left">
<div class="w-14 h-14 rounded-2xl bg-gray-50 dark:bg-gray-900 flex items-center justify-center text-gray-400 group-hover:bg-blue-600 group-hover:text-white transition-all shadow-sm">
<i class={`fa-solid ${k.tip === 'pravno' ? 'fa-building-shield' : 'fa-user-gear'} text-xl`}></i>
</div>
<div class="flex flex-col">
<div class="flex items-center gap-3">
<span class="font-black text-gray-900 dark:text-white text-xl uppercase tracking-tighter italic group-hover:text-blue-600 transition-colors leading-none">
{k.naziv}
</span>
<span class="text-[10px] font-black bg-gray-100 dark:bg-gray-900 px-2 py-0.5 rounded border border-gray-200 dark:border-gray-700 uppercase tracking-widest text-gray-500">
{k.grad}
</span>
</div>
<span class="text-gray-400 text-[10px] font-black uppercase tracking-widest mt-2">
OIB: <span class="text-gray-900 dark:text-gray-200">{k.oib}</span>
<span class="text-blue-600/30 mx-1">•</span>
Kontakt: {k.telefon}
</span>
</div>
</div>
<div class="flex items-center gap-8">
<div class="hidden lg:flex flex-col items-end text-right">
<span class="text-[8px] font-black uppercase text-gray-400 tracking-[0.2em]">Oprema u sustavu</span>
<span class="text-xs font-black uppercase italic dark:text-gray-300">
{k.broj_strojeva} Jedinica
</span>
</div>
<i class="fa-solid fa-arrow-right-long text-gray-200 group-hover:text-blue-600 group-hover:translate-x-2 transition-all"></i>
</div>
</a>
)) : (
<div class="p-24 text-center">
<i class="fa-solid fa-users-viewfinder text-4xl text-gray-100 dark:text-gray-700 mb-4 block"></i>
<p class="italic text-gray-400 text-[10px] uppercase tracking-widest">Nema kupaca u bazi</p>
</div>
)}
</div>
</div>
</div>
<div class="md:col-span-3">
<AkcijePanel tip="dashboard" />
</div>
</div>
</div>
</Layout>
<script>
import { initFilters } from "../../scripts/filters";
initFilters();
document.addEventListener('astro:after-swap', initFilters);
</script>

View File

@@ -0,0 +1,18 @@
---
// src/pages/login.astro
import Layout from "../layouts/Layout.astro";
import LoginForm from "../components/Form/LoginForm.jsx";
import WelcomeHeader from "../components/WelcomeHeader.astro";
---
<Layout title="Prijava u sustav" description="Prijava u terminalnu aplikaciju">
<div class="flex items-center justify-center px-4">
<!-- WELCOME HEADER -->
<WelcomeHeader />
<!-- LOGIN FORM -->
<LoginForm />
</div>
</Layout>

View File

@@ -0,0 +1,293 @@
---
// src/pages/operativa/radni-nalozi/[id].astro
import Layout from "../../../layouts/Layout.astro";
import AkcijePanel from "../../../components/AkcijePanel.astro";
import Gallery from "../../../components/Gallery.astro";
// Utiliti za vizualni prikaz
import { getStatusColorClass } from "../../../utils/ui";
const { id } = Astro.params;
const API_BASE = import.meta.env.PUBLIC_API_URL;
let nalog = null;
let dostupnaVozila = [];
try {
// 1. Dohvaćamo detaljne podatke o radnom nalogu (RadniNalogDetaljiSerializer)
const resNalog = await fetch(`${API_BASE}/operativa/radni-nalozi/${id}/?t=${Date.now()}`);
if (resNalog.ok) nalog = await resNalog.json();
// 2. Ako radni nalog nema vezan putni nalog, dohvaćamo popis vozila za dropdown u klijentskom otoku
if (nalog && !nalog.putni_nalog) {
const resVozila = await fetch(`${API_BASE}/fleet/vozila/`);
if (resVozila.ok) dostupnaVozila = await resVozila.json();
}
} catch (e) {
console.error("Greška pri dohvatu podataka s API-ja:", e);
}
if (!nalog) return Astro.redirect("/404");
// Priprema datuma za prikaz
const datumKreiranja = new Date(nalog.datum_kreiranja).toLocaleDateString('hr-HR', {
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
---
<Layout title={`Nalog #${nalog.broj_naloga}`}>
<div class="w-full space-y-10 pb-20">
<header class="px-2 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div>
<a href="/" class="text-blue-600 text-[10px] font-black uppercase tracking-[0.3em] hover:underline flex items-center gap-2 mb-6 no-underline">
<i class="fa-solid fa-arrow-left text-[8px]"></i> Povratak na dashboard
</a>
<h1 class="text-4xl md:text-6xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none">
Radni nalog <span class="text-blue-600">#{nalog.broj_naloga}</span>
</h1>
<div class="flex flex-wrap items-center gap-4 mt-4">
<p class="text-gray-500 dark:text-gray-400 font-medium italic text-sm">
Otvoreno: {datumKreiranja}h
</p>
<span class="hidden sm:block text-gray-300 dark:text-gray-700">|</span>
<p class="text-blue-600 dark:text-blue-400 font-black text-xs uppercase tracking-widest">
<i class="fa-solid fa-user-gear mr-1"></i> {nalog.izvrsitelj_ime || 'Nedodijeljeno'}
</p>
</div>
</div>
<div class="flex items-center gap-3 px-6 py-3 bg-white dark:bg-gray-800 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-xl">
<div class:list={[
"w-3 h-3 rounded-full",
getStatusColorClass(nalog.status),
{"animate-pulse": nalog.status === 'U_RADU'}
]}></div>
<span class="font-black uppercase text-xs tracking-widest text-gray-700 dark:text-gray-200">
{nalog.status_display || nalog.status}
</span>
</div>
</header>
<div class="grid md:grid-cols-10 gap-8 items-start px-2">
<div class="md:col-span-7 space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-[3rem] p-10 md:p-16 border border-gray-100 dark:border-gray-700 shadow-2xl shadow-blue-500/5 relative overflow-hidden">
<label class="text-[10px] font-black uppercase tracking-[0.3em] text-blue-600 block mb-6 italic">Opis kvara / Zadatak</label>
<p class="text-2xl md:text-3xl font-medium text-gray-700 dark:text-gray-200 italic leading-tight tracking-tight">
{nalog.opis_kvara}
</p>
</div>
<div class="grid sm:grid-cols-2 gap-6">
<div class="bg-gray-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden group">
<i class="fa-solid fa-screwdriver-wrench absolute -right-4 -bottom-4 text-8xl opacity-10 -rotate-12 group-hover:rotate-0 transition-transform duration-700"></i>
<label class="text-[9px] font-black uppercase text-gray-500 block mb-4 italic tracking-widest">Stroj na popravku</label>
<h3 class="text-2xl font-black uppercase tracking-tighter italic leading-none mb-2">{nalog.stroj?.naziv}</h3>
<span class="inline-block bg-blue-600 text-white px-3 py-1 rounded font-black text-[10px] tracking-widest uppercase mb-4">
SN: {nalog.stroj?.serijski_broj || 'Nema SN'}
</span>
<div class="space-y-1">
<p class="text-xs text-gray-400 font-medium uppercase">Radni sati: <span class="text-white font-black">{nalog.stroj?.radni_sati || 0} h</span></p>
<p class="text-xs text-gray-400 font-medium uppercase">Lokacija: <span class="text-white font-black">{nalog.stroj?.lokacija || 'Teren'}</span></p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-[2.5rem] p-8 border border-gray-100 dark:border-gray-700 shadow-sm">
<label class="text-[9px] font-black uppercase text-gray-400 block mb-4 italic tracking-widest">Klijent / Vlasnik</label>
<h3 class="text-2xl font-black uppercase tracking-tighter italic dark:text-white leading-none mb-2">{nalog.klijent?.naziv}</h3>
<p class="text-sm text-gray-500 font-medium">{nalog.klijent?.grad} <span class="mx-1 text-blue-500">•</span> OIB: {nalog.klijent?.oib}</p>
<div class="mt-6 flex flex-wrap gap-4">
<a href={`tel:${nalog.klijent?.telefon}`} class="flex items-center gap-2 text-blue-600 font-black text-[10px] uppercase no-underline hover:opacity-70 transition-opacity">
<div class="w-7 h-7 rounded-full bg-blue-50 flex items-center justify-center"><i class="fa-solid fa-phone"></i></div> Nazovi
</a>
<a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent((nalog.klijent?.adresa || '') + ' ' + (nalog.klijent?.grad || ''))}`} target="_blank" class="flex items-center gap-2 text-gray-500 font-black text-[10px] uppercase no-underline hover:opacity-70 transition-opacity">
<div class="w-7 h-7 rounded-full bg-gray-50 flex items-center justify-center"><i class="fa-solid fa-location-dot"></i></div> Navigacija
</a>
</div>
</div>
</div>
{nalog.vozilo ? (
<div class="bg-gray-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden group">
<i class="fa-solid fa-van-shuttle absolute -right-4 -bottom-4 text-8xl opacity-10 -rotate-12 group-hover:rotate-0 transition-transform duration-700"></i>
<label class="text-[9px] font-black uppercase text-gray-500 block mb-4 italic tracking-widest">Servisno vozilo</label>
<h3 class="text-2xl font-black uppercase tracking-tighter italic leading-none mb-2">{nalog.vozilo?.naziv}</h3>
<span class="inline-block bg-blue-600 text-white px-3 py-1 rounded font-black text-[10px] tracking-widest uppercase mb-4">
Registracija: {nalog.vozilo?.registracija || ''}
</span>
<div class="space-y-1">
<p class="text-xs text-gray-400 font-medium uppercase">Trenutni km: <span class="text-white font-black">{nalog.vozilo.trenutni_kilometri?.toLocaleString() || '0'} km</span></p>
<p class="text-xs text-gray-400 font-medium uppercase">Status vozila: <span class="text-white font-black">{nalog.vozilo?.status_prikaz || 'Aktivno na terenu'}</span></p>
</div>
</div>
) : (
<div class="bg-gray-50 dark:bg-gray-900/30 rounded-[2.5rem] p-8 border border-dashed border-gray-200 dark:border-gray-800 flex items-center gap-4 opacity-60">
<i class="fa-solid fa-truck-ramp-box text-gray-400"></i>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Radni nalog bez aktivnog putnog naloga / vozila</p>
</div>
)}
<div class="space-y-6">
<div class="flex items-center justify-between px-2">
<label class="text-[10px] font-black uppercase tracking-[0.3em] text-gray-400 italic">Foto dokumentacija terena</label>
<span class="text-[10px] font-black bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full text-gray-500">
{nalog.slike?.length || 0} SLIKA
</span>
</div>
<Gallery
images={nalog.slike}
galleryId={`gallery-rn-${nalog.id}`}
/>
</div>
</div>
<div class="md:col-span-3 space-y-6 sticky top-10">
<AkcijePanel tip="radni-nalog" podaci={nalog} />
<div class="bg-white dark:bg-gray-800 rounded-[2rem] p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
<h4 class="text-[9px] font-black uppercase text-gray-400 mb-4 tracking-widest italic">Logistika i Terenski Put</h4>
{nalog.putni_nalog ? (
<div class="bg-green-50 dark:bg-green-900/10 p-5 rounded-2xl border border-green-100 dark:border-green-900/20 flex flex-col gap-2">
<p class="text-xs font-black text-green-700 dark:text-green-400 leading-tight flex items-center gap-2">
<i class="fa-solid fa-file-circle-check text-sm"></i> Povezan putni nalog #{nalog.putni_nalog.broj_naloga}
</p>
<div class="text-[11px] text-gray-500 dark:text-gray-400 space-y-1 border-t border-b border-gray-100 dark:border-gray-700/50 py-2 my-1">
<p>Polazna kilometraža: <span class="font-black text-gray-700 dark:text-gray-200">{nalog.putni_nalog.pocetna_km?.toLocaleString()} km</span></p>
<p class="flex items-center gap-1.5">Status puta: <span class="inline-block bg-green-200 dark:bg-green-900/40 text-green-800 dark:text-green-300 text-[9px] font-black px-2 py-0.5 rounded uppercase tracking-wider">{nalog.putni_nalog.status}</span></p>
</div>
<a href={`/logistika/putni-nalozi/${nalog.putni_nalog.id}`} class="text-[10px] uppercase font-black text-blue-600 dark:text-blue-400 no-underline hover:underline pt-1 flex items-center gap-1">
Otvori putni nalog <i class="fa-solid fa-arrow-right text-[8px]"></i>
</a>
</div>
) : (
<div class="space-y-3 mt-2">
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mb-2">
Ovaj radni nalog trenutno nema vezan putni nalog. Za potrebe obrade dnevnica odaberite vozilo:
</p>
<select
id="pn-vozilo-select"
class="w-full p-3 text-xs rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 dark:text-white font-black uppercase tracking-wider focus:outline-none focus:border-blue-500 cursor-pointer"
>
<option value="">-- Odaberi Servisno Vozilo --</option>
{dostupnaVozila.map(v => (
<option value={v.id}>{v.registracija} - {v.naziv}</option>
))}
</select>
<button
id="pn-generiraj-btn"
data-nalog-id={nalog.id}
class="w-full py-4 px-6 bg-blue-600 hover:bg-blue-700 text-white font-black text-xs uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-500/20 text-center flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
>
<i id="pn-btn-icon" class="fa-solid fa-file-shield"></i>
<span id="pn-btn-text">Generiraj Putni Nalog</span>
</button>
</div>
)}
</div>
<div class="bg-white dark:bg-gray-800 rounded-[2rem] p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
<h4 class="text-[9px] font-black uppercase text-gray-400 mb-4 tracking-widest">Zadnja aktivnost</h4>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-xl bg-green-50 dark:bg-green-900/20 flex items-center justify-center text-green-600">
<i class="fa-solid fa-clock-rotate-left text-xs"></i>
</div>
<div>
<p class="text-[10px] text-gray-400 uppercase font-bold leading-none mb-1">Ažurirano</p>
<p class="text-xs font-black dark:text-gray-200 leading-none">
{new Date(nalog.datum_azuriranja).toLocaleDateString('hr-HR')} u {new Date(nalog.datum_azuriranja).toLocaleTimeString('hr-HR', {hour:'2-digit', minute:'2-digit'})}h
</p>
</div>
</div>
</div>
<div class="text-center p-6 border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-[2rem]">
<p class="text-[8px] font-black text-gray-300 dark:text-gray-600 uppercase tracking-[0.4em]">Sistemski ID: {nalog.id}</p>
</div>
</div>
</div>
</div>
</Layout>
<script>
import { createPutniNalog } from '../../../lib/api.js';
function initLogistika() {
const gumb = document.getElementById('pn-generiraj-btn');
const select = document.getElementById('pn-vozilo-select');
const btnIcon = document.getElementById('pn-btn-icon');
const btnText = document.getElementById('pn-btn-text');
if (!gumb || !select) return;
gumb.addEventListener('click', async (e) => {
e.preventDefault();
const radniNalogId = gumb.getAttribute('data-nalog-id');
const odabranoVoziloId = select.value;
// 1. Brza klijentska validacija
if (!odabranoVoziloId) {
if (typeof window !== 'undefined') {
window.showToast?.("Molimo odaberite službeno vozilo.", "error");
}
return;
}
// 2. Pokretanje vizualnog loading stanja
gumb.disabled = true;
select.disabled = true;
if (btnText) btnText.textContent = 'Generiranje...';
if (btnIcon) btnIcon.className = 'fa-solid fa-spinner animate-spin';
try {
// 3. Slanje zahtjeva na Django backend (Kroz pročišćeni api.js)
const data = await createPutniNalog(radniNalogId, odabranoVoziloId);
if (data) {
// KONZISTENTAN UKLOP (Opcija B):
// Spremamo poruku u formatu koji Toast.astro već automatski prepoznaje i čita nakon reloada
sessionStorage.setItem(
'pending_toast',
JSON.stringify({
type: 'success',
message: 'Putni nalog uspješno generiran!'
})
);
// Instantno osvježavanje stranice - SSR iscrtava novu povezanu zelenu karticu
window.location.reload();
} else {
// Ako je krah, handleResponse unutar api.js je već okinuo privremeni crveni showToast
ponistiLoading();
}
} catch (err) {
console.error("Greška unutar klijentske skripte logistike:", err);
if (typeof window !== 'undefined') {
window.showToast?.("Komunikacija s poslužiteljem nije uspjela.", "error");
}
ponistiLoading();
}
});
function ponistiLoading() {
gumb.disabled = false;
select.disabled = false;
if (btnText) btnText.textContent = 'Generiraj Putni Nalog';
if (btnIcon) btnIcon.className = 'fa-solid fa-file-shield';
}
}
// Pokretanje skripte ovisno o Astro životnom ciklusu stranice
initLogistika();
document.addEventListener('astro:page-load', initLogistika);
</script>

View File

@@ -0,0 +1,66 @@
---
// src/pages/operativa/radni-nalozi/index.astro
import Layout from "../../../layouts/Layout.astro";
import Button from "../../../components/Button.astro";
import AkcijePanel from "../../../components/AkcijePanel.astro";
import RadniNalogLista from "../../../components/RadniNalogLista.astro";
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
import NaslovList from "../../../components/NaslovList.astro";
// 1. DOHVAT PODATAKA
import { fetchRadniNalozi, fetchCurrentUser } from "../../../lib/api";
import { formatStatus, getStatusColorClass } from "../../../utils/ui";
import site from "../../../data/site.json";
// Paralelno dohvaćamo naloge i podatke o trenutnom korisniku
const klijentId = Astro.url.searchParams.get('klijent');
const statusParam = Astro.url.searchParams.get('status');
const [nalozi, user] = await Promise.all([
fetchRadniNalozi({ klijent: klijentId, status: statusParam }),
fetchCurrentUser()
]);
// 2. LOGIKA ZA HEADER I BROJAČE
const uRadu = nalozi.filter(n => n.status === 'u_radu').length;
const planirano = nalozi.filter(n => n.status === 'planirano').length;
const siteWelcomeHeader = site.navigation.find(({name}) => name === "Kalendar");
// Podaci za WelcomeHeader
const imeKorisnika = user?.first_name || "Serviser";
---
<Layout title="Radni nalozi | Arhiva">
<div class="w-full space-y-10">
<!-- WELCOME HEADER -->
<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">
<!-- 3. LISTA NALOGA -->
<!-- Postavljamo prikaziNaslov={false} jer smo naslov već ispisali iznad preko NaslovList -->
<RadniNalogLista
limit={7}
statusFilter={statusParam as any}
prikaziNaslov={true}
/>
</div>
<!-- DESNI PANEL - AKCIJE -->
<div class="md:col-span-3">
<AkcijePanel tip="dashboard" />
</div>
</div>
</div>
</Layout>
<script>
import { initFilters } from "../../../scripts/filters";
initFilters();
document.addEventListener('astro:after-swap', initFilters);
</script>

View File

@@ -0,0 +1,232 @@
---
// src/pages/operativa/radni-nalozi/novi.astro
import Layout from "../../../layouts/Layout.astro";
import Button from "../../../components/Button.astro";
import { fetchKupciData, fetchStrojeviData } from "../../../lib/api";
import WelcomeHeader from "../../../components/WelcomeHeader.astro";
// 1. DOHVAT PODATAKA NA SERVERU
const { kupci } = await fetchKupciData();
const { strojevi } = await fetchStrojeviData();
---
<Layout title="Novi Radni Nalog">
<div class="max-w-3xl mx-auto space-y-8 px-2 py-10">
<WelcomeHeader />
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6 border-b border-gray-100 dark:border-gray-700 pb-6 px-2">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Broj Naloga</label>
<div id="broj-naloga-display" class="w-full bg-gray-100 dark:bg-gray-900/50 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-2xl p-5 font-black text-lg text-gray-400 flex items-center gap-3">
<i class="fa-solid fa-barcode opacity-30"></i>
<span id="broj-text">AUTO-GENERIRANO</span>
</div>
<p class="text-[9px] text-gray-400 ml-1 italic">* Broj će biti dodijeljen nakon spremanja</p>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Datum Kreiranja</label>
<div class="w-full bg-gray-50 dark:bg-gray-900 border-2 border-transparent rounded-2xl p-5 font-bold text-lg dark:text-white flex items-center gap-3">
<i class="fa-solid fa-calendar-day text-indigo-600"></i>
{new Date().toLocaleDateString('hr-HR')}
</div>
</div>
</div>
<form id="novi-nalog-form" class="space-y-6">
<div class="bg-white dark:bg-gray-800 p-8 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-xl space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Odabir Klijenta *</label>
<select name="klijent" required class="w-full bg-gray-50 dark:bg-gray-900 border-2 border-transparent focus:border-indigo-600 rounded-2xl p-5 font-bold text-lg appearance-none cursor-pointer transition-colors dark:text-white">
<option value="">-- Odaberi klijenta --</option>
{kupci.map(k => <option value={k.id}>{k.naziv}</option>)}
</select>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Odabir Stroja *</label>
<div class="custom-select-container relative w-full">
<div id="stroj-trigger" class="w-full bg-gray-50 dark:bg-gray-900 border-2 border-transparent p-5 font-bold text-lg rounded-2xl cursor-pointer flex justify-between items-center dark:text-white">
<span id="selected-stroj-label">-- Prvo odaberi klijenta --</span>
<i class="fa-solid fa-chevron-down text-xs"></i>
</div>
<input type="hidden" name="stroj" id="real-stroj-input" required />
<div id="stroj-options" class="hidden absolute z-50 w-full mt-2 bg-gray-900 border border-gray-700 rounded-2xl shadow-2xl overflow-hidden max-h-60 overflow-y-auto">
{strojevi.map(s => (
<div
class="stroj-opt p-4 hover:bg-indigo-600 cursor-pointer transition-colors text-white font-bold italic border-b border-gray-800 last:border-0 hidden"
data-value={s.id}
data-vlasnik={s.vlasnik}
>
{s.naziv} ({s.registracija || s.serijski_broj})
</div>
))}
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 p-8 rounded-[3rem] border border-gray-100 dark:border-gray-700 shadow-xl space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Opis problema / Radova *</label>
<textarea name="opis_kvara" required rows="4" class="w-full bg-gray-50 dark:bg-gray-900 border-none rounded-3xl p-6 font-medium italic text-lg focus:ring-2 focus:ring-indigo-600 transition-all dark:text-white" placeholder="Što treba napraviti?"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic">Trenutni Radni Sati</label>
<input type="number" name="radni_sati" step="0.1" class="w-full bg-gray-50 dark:bg-gray-900 border-none rounded-2xl p-5 font-black text-2xl text-indigo-600 focus:ring-2 focus:ring-indigo-600 transition-all" placeholder="0.0">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-gray-400 italic block">Prioritet zahvata</label>
<div class="flex gap-3 mt-2">
<input type="checkbox" name="hitno" value="true" id="hitno" class="hidden peer">
<label for="hitno" class="flex-1 text-center py-4 bg-gray-50 dark:bg-gray-900 rounded-2xl text-[10px] font-black uppercase peer-checked:bg-red-600 peer-checked:text-white cursor-pointer transition-all border-2 border-transparent peer-checked:border-red-700 dark:text-gray-400">
<i class="fa-solid fa-triangle-exclamation mr-2"></i> Hitno
</label>
</div>
</div>
</div>
</div>
<div class="bg-indigo-600 p-8 rounded-[3rem] shadow-xl text-white group relative overflow-hidden">
<i class="fa-solid fa-camera absolute -right-4 -top-4 text-7xl opacity-10 group-hover:rotate-12 transition-transform duration-500"></i>
<label class="text-[10px] font-black uppercase tracking-[0.2em] mb-4 block opacity-70 italic">Slike kvara/terena (opcionalno)</label>
<input type="file" name="slike" multiple accept="image/*" class="w-full text-xs file:mr-4 file:py-3 file:px-6 file:rounded-full file:border-0 file:text-[10px] file:font-black file:uppercase file:bg-white file:text-indigo-600 hover:file:bg-indigo-50 cursor-pointer transition-all" />
</div>
<div class="pt-4">
<Button
type="submit"
id="submit-btn"
variant="primary"
class="w-full py-8 text-xl shadow-2xl shadow-indigo-500/20"
>
Kreiraj i pokreni nalog <i class="fa-solid fa-bolt ml-2"></i>
</Button>
</div>
</form>
</div>
</Layout>
<script>
import { createNalog, fetchSljedeciBrojNaloga } from "../../../lib/api";
const form = document.getElementById('novi-nalog-form') as HTMLFormElement;
const klijentSelect = form.querySelector('select[name="klijent"]') as HTMLSelectElement;
const trigger = document.getElementById('stroj-trigger');
const optionsList = document.getElementById('stroj-options');
const label = document.getElementById('selected-stroj-label');
const realInput = document.getElementById('real-stroj-input') as HTMLInputElement;
const strojOptions = document.querySelectorAll('.stroj-opt');
// Inicijalizacija broja naloga na klijentu
async function initBrojNaloga() {
const displayElement = document.getElementById('broj-text');
if (displayElement) {
const data = await fetchSljedeciBrojNaloga();
displayElement.textContent = data.broj_naloga;
}
}
initBrojNaloga();
// 1. DROPDOWN LOGIKA
trigger?.addEventListener('click', () => {
optionsList?.classList.toggle('hidden');
trigger.classList.toggle('border-indigo-600');
});
strojOptions.forEach(opt => {
opt.addEventListener('click', () => {
let val = opt.getAttribute('data-value');
if (val) val = val.replace(/['"]+/g, '').trim();
if (label && realInput && optionsList) {
label.textContent = opt.textContent;
realInput.value = val || "";
optionsList.classList.add('hidden');
}
});
});
// 2. FILTRIRANJE STROJEVA
klijentSelect?.addEventListener('change', (e) => {
const klijentId = (e.target as HTMLSelectElement).value;
if (label && realInput) {
label.textContent = klijentId ? "-- Odaberi stroj --" : "-- Prvo odaberi klijenta --";
realInput.value = "";
}
strojOptions.forEach(item => {
const vlasnikId = item.getAttribute('data-vlasnik');
if (vlasnikId == klijentId) item.classList.remove('hidden');
else item.classList.add('hidden');
});
});
// 3. SLANJE NA API
form.addEventListener('submit', async (e) => {
e.preventDefault();
const gumb = document.getElementById('submit-btn') as HTMLButtonElement;
if (!gumb) return;
gumb.setAttribute('disabled', 'true');
const originalContent = gumb.innerHTML;
gumb.innerHTML = '<i class="fa-solid fa-circle-notch animate-spin mr-2"></i> Sinkronizacija...';
const formData = new FormData(form);
// Brišemo broj_naloga kako bi backend sam aktivirao save() sekvencu
formData.delete('broj_naloga');
const userString = localStorage.getItem('user_info');
if (userString) {
const user = JSON.parse(userString);
formData.append('izvrsitelj', user.id);
}
try {
const nalog = await createNalog(formData);
if (nalog && nalog.id) {
// Budući da views.py sada radi refresh_from_db(), nalog.broj_naloga je ovdje 100% čisti 'RN-2026-XXXX'
sessionStorage.setItem(
'pending_toast',
JSON.stringify({
type: 'success',
message: `Radni nalog ${nalog.broj_naloga} uspješno kreiran!`
})
);
window.location.href = `/operativa/radni-nalozi/${nalog.id}`;
} else {
ponistiLoading(gumb, originalContent);
}
} catch (err) {
console.error("Kritičan krah pri izradi naloga:", err);
if (typeof window !== 'undefined') {
window.showToast?.("Komunikacija s poslužiteljem nije uspjela.", "error");
}
ponistiLoading(gumb, originalContent);
}
});
function ponistiLoading(gumb: HTMLElement, originalContent: string) {
gumb.removeAttribute('disabled');
gumb.innerHTML = originalContent;
}
document.addEventListener('click', (e) => {
if (trigger && optionsList && !trigger.contains(e.target as Node) && !optionsList.contains(e.target as Node)) {
optionsList.classList.add('hidden');
trigger.classList.remove('border-indigo-600');
}
});
</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,70 @@
// // src/scripts/filters.js
// Upravlja sa item.style.display na osnovu aktivnog filtera i data-status atributa
// Također upravlja sa stilovima kartica (ring, border) i naslovom filtera
// export function initFilters() {
// const cards = document.querySelectorAll('.stat-card');
// const items = document.querySelectorAll('.nalog-item');
// const title = document.getElementById('filter-title');
// let activeFilter = null;
// cards.forEach(card => {
// card.addEventListener('click', () => {
// const filter = card.getAttribute('data-filter');
// if (activeFilter === filter) {
// activeFilter = null;
// items.forEach(item => item.style.display = 'flex');
// cards.forEach(c => c.classList.remove('ring-4', 'ring-blue-600/20', 'border-blue-600'));
// if (title) title.innerText = "Zadnje aktivnosti";
// } else {
// activeFilter = filter;
// cards.forEach(c => c.classList.remove('ring-4', 'ring-blue-600/20', 'border-blue-600'));
// card.classList.add('ring-4', 'ring-blue-600/20', 'border-blue-600');
// if (title) title.innerText = `Filtrirano: ${filter.replace('_', ' ')}`;
// items.forEach(item => {
// const status = item.getAttribute('data-status');
// item.style.display = (status === filter) ? 'flex' : 'none';
// });
// }
// });
// });
// }
// src/scripts/filters.js
export function initFilters() {
const cards = document.querySelectorAll('.stat-card');
// Dohvaćamo trenutni aktivni filter iz URL-a
const urlParams = new URLSearchParams(window.location.search);
const currentActiveFilter = urlParams.get('status');
cards.forEach(card => {
const filterValue = card.getAttribute('data-filter');
// Dodaj vizualno aktivno stanje ako se URL podudara s gumbom
if (currentActiveFilter === filterValue) {
card.classList.add('ring-4', 'ring-blue-600/20', 'border-blue-600');
}
card.addEventListener('click', () => {
const newParams = new URLSearchParams(window.location.search);
if (currentActiveFilter === filterValue) {
// Ako kliknemo na već aktivni filter, poništavamo ga
newParams.delete('status');
} else {
// Inače postavljamo novi filter
newParams.set('status', filterValue);
}
// Osvježi stranicu s novim parametrom - to okida novi API request na serveru
window.location.search = newParams.toString();
});
});
}

View File

@@ -0,0 +1,40 @@
// src/stores/appState.js
import { atom } from 'nanostores';
import { resetAllStores } from './rootStore'; // Centralni reset svih ostalih store-ova
// 1. Jedinstveni izvor istine za cijelu aplikaciju
export const $currentUser = atom(null);
/**
* DRY AKCIJA: Kompletna prijava korisnika u sustav
* @param {Object} stvarniKorisnik - Profilni podaci s Django backenda
* @param {string} token - JWT access token
*/
export function loginKorisnika(stvarniKorisnik, token) {
if (typeof window !== 'undefined') {
sessionStorage.setItem('access_token', token);
}
// Upisujemo korisnika u atom - sve komponente (uključujući AuthStatus) se instantno ažuriraju
$currentUser.set(stvarniKorisnik);
}
/**
* DRY AKCIJA: Potpuna i sigurna odjava iz sustava
*/
export function logoutKorisnika() {
// 1. Čistimo token s jednog mjesta
if (typeof window !== 'undefined') {
sessionStorage.removeItem('access_token');
}
// 2. Pokrećemo tvoj centralni reset koji čisti sva ostala stanja (naloge, vozila...)
resetAllStores();
// 3. Postavljamo i samog korisnika na null
$currentUser.set(null);
// 4. Preusmjeravamo na login
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}

View File

@@ -0,0 +1,24 @@
// src/stores/fleetStore.js
import { atom } from 'nanostores';
import { isClient, cacheImagesForOffline } from '../utils/cacheHelper';
// 1. Stanja specifična za Vozni Park
export const $voziloDetalji = atom(null);
export const $offlineStatus = atom(isClient ? !navigator.onLine : false);
if (isClient) {
window.addEventListener('online', () => $offlineStatus.set(false));
window.addEventListener('offline', () => $offlineStatus.set(true));
}
// 2. Akcija koja koristi isti predložak
export async function setVoziloDetalji(vozilo) {
$voziloDetalji.set(vozilo);
if (!vozilo || !vozilo.slike_vozila) return;
const stazeSlika = vozilo.slike_vozila.map(s => s.datoteka);
// 🚀 Isti helper, drugi spremnik (npr. fleet-slike-cache)
await cacheImagesForOffline(stazeSlika, 'fleet-slike-cache', 600);
}

View File

@@ -0,0 +1,10 @@
// galleryStore.js
import { atom } from 'nanostores';
// Početno stanje je prazan niz
export const $galleryImages = atom([]);
// Funkcija za ažuriranje (možeš je pozvati iz bilo koje komponente)
export const updateGallery = (newImages) => {
$galleryImages.set(newImages);
};

View File

@@ -0,0 +1,8 @@
// src/stores/kalendarStore.js
import { atom } from 'nanostores';
export const mojRaspored = atom([]);
export const kalendarDogadaji = atom([]);
export const setMojRaspored = (data) => mojRaspored.set(data);
export const setKalendarDogadaji = (data) => kalendarDogadaji.set(data);

View File

@@ -0,0 +1,6 @@
// src/stores/kupciStore.js
import { atom } from 'nanostores';
export const kupci = atom([]);
export const setKupci = (data) => kupci.set(data);

View File

@@ -0,0 +1,36 @@
// src/stores/operativaStore.js
import { atom } from 'nanostores';
import { isClient, cacheImagesForOffline } from '../utils/cacheHelper';
// 1. Globalna stanja modula
export const $radniNalogDetalji = atom(null);
export const $offlineStatus = atom(isClient ? !navigator.onLine : false);
// 2. Slušač mrežnog statusa (izvršava se samo na klijentu)
if (isClient) {
window.addEventListener('online', () => $offlineStatus.set(false));
window.addEventListener('offline', () => $offlineStatus.set(true));
}
/**
* Funkcija za postavljanje podataka u trgovinu uz automatsku offline sinkronizaciju slika
* @param {Object} nalog - Objekt radnog naloga s backenda
*/
export async function setNalogDetalji(nalog) {
// Instantno punimo store tekstualnim podacima (brzi UI rendering)
$radniNalogDetalji.set(nalog);
// Ako nalog nema slika, prekidamo daljnji proces keširanja
if (!nalog || !nalog.slike || nalog.slike.length === 0) return;
// Izvlačimo samo čiste URL-ove slika iz objekata
const stazeSlika = nalog.slike.map(s => s.slika);
// 🚀 KORISTIMO UTILS TEMPLATE: Pokrećemo izoliranu i sigurnu optimizaciju
await cacheImagesForOffline(stazeSlika, 'operativa-slike-cache', 600);
}
// 3. Opcionalno: Pomoćna akcija za čišćenje stanja kod navigacije
export function ocistiNalogDetalji() {
$radniNalogDetalji.set(null);
}

View File

@@ -0,0 +1,42 @@
// src/stores/rootStore.js
import { radniNalozi, putniNalozi, serviseri, activeNalogId } from './operativaStore';
import { vozila, strojevi } from './fleetStore';
import { kupci } from './kupciStore';
import { mojRaspored, kalendarDogadaji } from './kalendarStore';
import { $galleryImages } from './galleryStore';
import { $toasts } from './toastStore';
// 🚀 KLJUČNI DODATAK: Uvozimo autentifikacijska stanja iz appState-a
import { $currentUser, activeUserRole } from './appState';
/**
* Resetira apsolutno sva stanja unutar aplikacije.
* Koristi se primarno prilikom odjave korisnika (Logout) kako bi se spriječilo
* curenje podataka u memoriji preglednika između različitih sesija.
*/
export function resetAllStores() {
// --- Čišćenje operative ---
radniNalozi.set([]);
putniNalozi.set([]);
serviseri.set([]);
activeNalogId.set(null);
// --- Čišćenje flote i klijenata ---
vozila.set([]);
strojevi.set([]);
kupci.set([]);
// --- Čišćenje kalendara i rasporeda ---
mojRaspored.set([]);
kalendarDogadaji.set([]);
// --- Čišćenje sučelja (galerije i obavijesti) ---
$galleryImages.set([]);
$toasts.set([]);
// --- 🚀 RESTART AUTENTIFIKACIJSKIH ATOMA ---
$currentUser.set(null);
activeUserRole.set('GOST');
console.log("Sustav ServisLog je uspješno resetiran: svi podaci u memoriji su očišćeni.");
}

View File

@@ -0,0 +1,25 @@
// src/stores/toastStore.js
import { atom } from 'nanostores';
// 1. Inicijalno stanje trgovine (početno je null jer nema aktivne obavijesti)
export const $toast = atom(null);
/**
* 🚀 Centralna funkcija za ispaljivanje obavijesti kroz ERP sustav.
* @param {string} message - Tekstualna poruka koja se prikazuje serviseru
* @param {'success' | 'error'} type - Tip obavijesti (zelena ili crvena)
*/
export function showToast(message, type = 'success') {
$toast.set({
message,
type,
id: Date.now() // Jedinstveni vremenski pečat (korisno za re-okidanje istih poruka)
});
}
/**
* 🚀 Funkcija za ručno ili automatsko čišćenje obavijesti s ekrana (Higijena sučelja)
*/
export function ukloniToast() {
$toast.set(null);
}

View File

@@ -0,0 +1,67 @@
/* 1. Uvezi Tailwind v4 */
@import "tailwindcss";
/* 2. Reci Tailwindu da skenira Flowbite datoteke unutar node_modules (zamjena za staro 'content' polje) */
@source "../../node_modules/flowbite/**/*.js";
/* 3. Ako koristiš Flowbite-ov CSS plugin, u v4 se on unosi ovako: */
@plugin "flowbite/plugin";
@font-face {
font-family: 'Inter';
src: url('/webfonts/inter/inter-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap; /* 🚀 KLJUČNO: Preglednik odmah prikazuje fallback font dok se Inter ne učita */
}
@font-face {
font-family: 'Inter';
src: url('/webfonts/inter/inter-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/webfonts/inter/inter-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/webfonts/inter/inter-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/webfonts/inter/inter-800.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
body {
font-family: 'Inter', sans-serif;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

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

View File

@@ -0,0 +1,50 @@
// src/utils/cacheHelper.js
/**
* Provjerava je li aplikacija trenutno u pregledniku (klijent)
*/
export const isClient = typeof window !== 'undefined';
/**
* Generira siguran, optimiziran proxy URL za sliku na temelju zadane širine
* @param {string} originalUrl - Izvorna staza slike (npr. /media/slika.jpg)
* @param {number} width - Željena širina za on-the-fly kompresiju
* @returns {string}
*/
export function getOptimizedImageUrl(originalUrl, width = 600) {
if (!originalUrl) return '';
// Ako je URL već proxy ili eksterni, vrati ga, inače ga omotaj
if (originalUrl.includes('proxy-image')) return originalUrl;
return `/api/operativa/proxy-image/?url=${encodeURIComponent(originalUrl)}&w=${width}`;
}
/**
* Asinkrano preuzima i pohranjuje slike u lokalni Browser Cache Storage za offline rad
* @param {Array<string>} urlArray - Niz originalnih URL-ova slika
* @param {string} cacheName - Naziv cache spremnika (npr. 'operativa-cache')
* @param {number} width - Širina na kojoj se slike keširaju
*/
export async function cacheImagesForOffline(urlArray, cacheName = 'global-media-cache', width = 600) {
if (!isClient || !urlArray || urlArray.length === 0) return;
try {
const cache = await caches.open(cacheName);
for (const originalUrl of urlArray) {
if (!originalUrl) continue;
const optimiziraniUrl = getOptimizedImageUrl(originalUrl, width);
const vecKesirano = await cache.match(optimiziraniUrl);
if (!vecKesirano) {
console.log(`[Offline Cache] Keširam za izvanmrežni rad: ${optimiziraniUrl}`);
// Fetch prolazi kroz Django Proxy, povlači WebP i sprema ga u lokalnu memoriju
await cache.add(optimiziraniUrl).catch(err =>
console.warn(`[Offline Cache] Neuspješan fetch za: ${optimiziraniUrl}`, err)
);
}
}
} catch (err) {
console.error("[Offline Cache Krah]:", err);
}
}

View File

@@ -0,0 +1,111 @@
// src/utils/ui.js
/**
* Vraća set Tailwind klasa za specifičnu temu
* @param {string} tema - 'indigo', 'blue', 'emerald', 'amber', 'red'
*/
export function getThemeClasses(tema = "indigo") {
const themes = {
indigo: {
bg: "bg-indigo-50",
bgDark: "dark:bg-indigo-900/20",
text: "text-indigo-600",
textDark: "dark:text-indigo-400",
border: "border-indigo-100",
borderDark: "dark:border-indigo-900/30",
shadow: "shadow-indigo-500/5",
hover: "hover:bg-indigo-50/30 dark:hover:bg-indigo-900/10",
icon: "group-hover:bg-indigo-600 group-hover:text-white"
},
blue: {
bg: "bg-blue-50",
bgDark: "dark:bg-blue-900/20",
text: "text-blue-600",
textDark: "dark:text-blue-400",
border: "border-blue-100",
borderDark: "dark:border-blue-900/30",
shadow: "shadow-blue-500/5",
hover: "hover:bg-blue-50/30 dark:hover:bg-blue-900/10",
icon: "group-hover:bg-blue-600 group-hover:text-white"
},
emerald: {
bg: "bg-emerald-50",
bgDark: "dark:bg-emerald-900/20",
text: "text-emerald-600",
textDark: "dark:text-emerald-400",
border: "border-emerald-100",
borderDark: "dark:border-emerald-900/30",
shadow: "shadow-emerald-500/5",
hover: "hover:bg-emerald-50/30 dark:hover:bg-emerald-900/10",
icon: "group-hover:bg-emerald-600 group-hover:text-white"
},
amber: {
bg: "bg-amber-50",
bgDark: "dark:bg-amber-900/20",
text: "text-amber-600",
textDark: "dark:text-amber-400",
border: "border-amber-100",
borderDark: "dark:border-amber-900/30",
shadow: "shadow-amber-500/5",
hover: "hover:bg-amber-50/30 dark:hover:bg-amber-900/10",
icon: "group-hover:bg-amber-600 group-hover:text-white"
},
red: {
bg: "bg-red-50",
bgDark: "dark:bg-red-900/20",
text: "text-red-600",
textDark: "dark:text-red-400",
border: "border-red-100",
borderDark: "dark:border-red-900/30",
shadow: "shadow-red-500/5",
hover: "hover:bg-red-50/30 dark:hover:bg-red-900/10",
icon: "group-hover:bg-red-600 group-hover:text-white"
}
};
return themes[tema] || themes.indigo;
}
/**
* Određuje boju kružića i animaciju na temelju statusa (Vozila + Radni Nalozi)
*/
export function getStatusColorClass(status) {
// 1. Provjera postoji li status. Ako ne, vrati sivu boju.
if (!status) return 'bg-gray-400';
// 2. Siguran poziv toLowerCase()
const s = String(status).toLowerCase();
switch (s) {
case 'u_radu': return 'bg-yellow-500 animate-pulse';
case 'planirano':
case 'aktivan': return 'bg-blue-500';
case 'zavrseno': return 'bg-emerald-500';
case 'naplaceno': return 'bg-gray-500 opacity-50';
case 'servis':
case 'hitno': return 'bg-red-500 animate-pulse';
default: return 'bg-gray-400';
}
}
/**
* Pretvara tehnički status u ljudima čitljiv format
* Dodana provjera da spriječi toLowerCase error
*/
export function formatStatus(status) {
// Provjera ako je status prazan
if (!status) return 'Nepoznato';
const labels = {
'planirano': 'Planirano',
'u_radu': 'U radu',
'zavrseno': 'Završeno',
'naplaceno': 'Naplaćeno',
'aktivan': 'Aktivan',
'servis': 'Na servisu',
'hitno': 'HITNO'
};
const s = String(status).toLowerCase();
return labels[s] || s;
}

View File

@@ -0,0 +1,14 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}