izradena poslovna logika
This commit is contained in:
24
002.FRONTEND/.gitignore
vendored
Normal file
24
002.FRONTEND/.gitignore
vendored
Normal 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
002.FRONTEND/.vscode/extensions.json
vendored
Normal file
4
002.FRONTEND/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
002.FRONTEND/.vscode/launch.json
vendored
Normal file
11
002.FRONTEND/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
002.FRONTEND/Dockerfile
Normal file
30
002.FRONTEND/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# --- 1. FAZA: Izgradnja (Build) ---
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiramo samo datoteke ovisnosti kako bismo iskoristili Docker cache
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Kopiramo ostatak izvornog koda i pokrećemo build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- 2. FAZA: Pokretanje (Run) ---
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Definiramo produkcijsko okruženje
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
|
||||
# Kopiramo samo izgrađene datoteke iz prve faze (dist) i potrebne module
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
# Pokretanje aplikacije izravno preko Node-a
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
43
002.FRONTEND/README.md
Normal file
43
002.FRONTEND/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 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).
|
||||
38
002.FRONTEND/astro.config.mjs
Normal file
38
002.FRONTEND/astro.config.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
// @ts-check
|
||||
import node from '@astrojs/node';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import preact from '@astrojs/preact';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
vite: {
|
||||
build: {
|
||||
// Smanji intenzitet optimizacije tijekom dev-a
|
||||
minify: false,
|
||||
cssMinify: false,
|
||||
},
|
||||
server: {
|
||||
allowedHosts: ['.mitteworkspace.cloud'],
|
||||
watch: {
|
||||
ignored: ['**/node_modules/**', '**/dist/**'],
|
||||
usePolling: true,
|
||||
interval: 1000
|
||||
},
|
||||
hmr: {
|
||||
protocol: 'wss', // ili 'wss' ako koristiš HTTPS
|
||||
clientPort: 443 // ili odgovarajući port ako nije 443
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
integrations: [preact({ devtools: true })]
|
||||
});
|
||||
4589
002.FRONTEND/package-lock.json
generated
Normal file
4589
002.FRONTEND/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
002.FRONTEND/package.json
Normal file
21
002.FRONTEND/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "operativa-frontend",
|
||||
"type": "module",
|
||||
"version": "0.0.2",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev --host",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.1.1",
|
||||
"@astrojs/preact": "^5.1.3",
|
||||
"@nanostores/preact": "^1.1.0",
|
||||
"astro": "^6.3.7",
|
||||
"nanostores": "^1.3.0"
|
||||
}
|
||||
}
|
||||
BIN
002.FRONTEND/public/favicon.ico
Normal file
BIN
002.FRONTEND/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
9
002.FRONTEND/public/favicon.svg
Normal file
9
002.FRONTEND/public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
22
002.FRONTEND/src/components/Button.jsx
Normal file
22
002.FRONTEND/src/components/Button.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/components/Button.jsx
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Button({ children, onClick, loading, disabled, variant = 'primary', label, type = 'button' }) {
|
||||
const isBlocked = loading || disabled;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={isBlocked}
|
||||
onClick={onClick}
|
||||
class={`btn btn-${variant} ${isBlocked ? 'opacity-50' : 'opacity-100'}`}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
cursor: isBlocked ? 'not-allowed' : 'pointer',
|
||||
transition: 'opacity 0.2s'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Spremanje...' : (children || label)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
45
002.FRONTEND/src/components/FleetStrojeviLista.jsx
Normal file
45
002.FRONTEND/src/components/FleetStrojeviLista.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/components/fleet/FleetStrojeviLista.jsx
|
||||
import { h } from 'preact';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { strojevi, setStrojevi } from '../stores/fleetStore';
|
||||
|
||||
export default function FleetStrojeviLista({ initialData = [] }) {
|
||||
useEffect(() => {
|
||||
if (initialData.length > 0) setStrojevi(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
const list = useStore(strojevi);
|
||||
|
||||
if (!list.length) return <div>Učitavanje strojeva...</div>;
|
||||
|
||||
return (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{list.map(s => (
|
||||
<div key={s.id} class="p-6 border border-gray-200 rounded-2xl bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="text-xl font-black text-gray-900">{s.naziv}</h3>
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-bold rounded-full uppercase">
|
||||
{s.tip_human_readable}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="text-sm text-gray-600 space-y-1 mb-4">
|
||||
<li><strong>Marka/Model:</strong> {s.marka} {s.model_stroja}</li>
|
||||
<li><strong>Serijski br:</strong> {s.serijski_broj}</li>
|
||||
<li><strong>Vlasnik:</strong> {s.vlasnik_naziv}</li>
|
||||
<li><strong>Radni sati:</strong> {s.radni_sati} h</li>
|
||||
{s.registracija && <li><strong>Reg:</strong> {s.registracija}</li>}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={`/fleet/strojevi/${s.id}`}
|
||||
class="block text-center w-full py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Pregled detalja
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
002.FRONTEND/src/components/FleetVozilaLista.jsx
Normal file
42
002.FRONTEND/src/components/FleetVozilaLista.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// src/components/fleet/FleetVozilaLista.jsx
|
||||
import { h } from 'preact';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { vozila, setVozila } from '../stores/fleetStore';
|
||||
|
||||
export default function FleetVozilaLista({ initialData = [], baseUrl }) {
|
||||
useEffect(() => {
|
||||
if (initialData.length > 0) setVozila(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
const list = useStore(vozila);
|
||||
|
||||
if (!list.length) return <div>Učitavanje voznog parka...</div>;
|
||||
|
||||
return (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{list.map(v => (
|
||||
<div key={v.id} class="p-6 border border-gray-200 rounded-2xl bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="text-xl font-black text-gray-900">{v.naziv}</h3>
|
||||
<span class="px-2 py-1 bg-green-100 text-green-800 text-xs font-bold rounded-full uppercase">
|
||||
{v.status_prikaz}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="text-sm text-gray-600 space-y-2 mb-4">
|
||||
<li><strong>Registracija:</strong> {v.registracija}</li>
|
||||
<li><strong>Trenutna km:</strong> {v.trenutni_kilometri.toLocaleString()} km</li>
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={`${baseUrl}/${v.id}`}
|
||||
class="block text-center w-full py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Pregled detalja
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
002.FRONTEND/src/components/Gallery.jsx
Normal file
31
002.FRONTEND/src/components/Gallery.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// src/components/Gallery.jsx
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Gallery({ images = [] }) {
|
||||
// Uvijek vraćamo isti 'div' wrapper da spriječimo hydration mismatch
|
||||
return (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{!images || images.length === 0 ? (
|
||||
<p class="text-sm text-gray-500 italic">Nema fotografija za ovaj nalog.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '1rem' }}>
|
||||
{images.map((item) => (
|
||||
<a
|
||||
href={item.slika}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
key={item.id}
|
||||
style={{ display: 'block', borderRadius: '8px', overflow: 'hidden', border: '1px solid #333' }}
|
||||
>
|
||||
<img
|
||||
src={item.slika}
|
||||
alt={item.opis || 'Foto dokumentacija'}
|
||||
style={{ width: '100%', height: '150px', objectFit: 'cover' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
002.FRONTEND/src/components/Login.jsx
Normal file
55
002.FRONTEND/src/components/Login.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { login } from '../utils/users'; // Uvoz iz novog modula
|
||||
import Button from './Button.jsx'; // Koristimo tvoju Button komponentu
|
||||
import { navigate } from 'astro:transitions/client';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(email, password);
|
||||
|
||||
if (result.success) {
|
||||
// Navigiraj bez "hard reloada"
|
||||
navigate('/');
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="max-w-md mx-auto mt-20 p-8 bg-gray-800 rounded-2xl shadow-xl text-white">
|
||||
<h1 class="text-2xl font-bold mb-6">Prijava u ServisLog</h1>
|
||||
<form onSubmit={handleLogin} class="space-y-4">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
class="w-full p-3 rounded bg-gray-700 border border-gray-600"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Lozinka"
|
||||
class="w-full p-3 rounded bg-gray-700 border border-gray-600"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
Prijava
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
002.FRONTEND/src/components/LogoutButton.jsx
Normal file
14
002.FRONTEND/src/components/LogoutButton.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { h } from 'preact';
|
||||
import { logoutUser } from '../utils/users'; // Import iz utils-a
|
||||
|
||||
export default function LogoutButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={logoutUser}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors font-bold uppercase text-[10px] tracking-widest italic"
|
||||
>
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
Odjava
|
||||
</button>
|
||||
);
|
||||
}
|
||||
71
002.FRONTEND/src/components/RadniNalogDisplay.jsx
Normal file
71
002.FRONTEND/src/components/RadniNalogDisplay.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// src/components/RadniNalogDisplay.jsx
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $radniNalogDetalji, setNalogDetalji } from '../stores/operativaStore';
|
||||
import RadniNalogEditForm from './edit/RadniNalogEditForm';
|
||||
import Gallery from './Gallery';
|
||||
import RadniNalogLista from './RadniNalogLista';
|
||||
|
||||
const VoziloInfo = ({ vozilo }) => (
|
||||
<section>
|
||||
<h3>Servisno vozilo</h3>
|
||||
{vozilo ? (
|
||||
<ul>
|
||||
<li><strong>Vozilo:</strong> {vozilo.naziv}</li>
|
||||
<li><strong>Registracija:</strong> {vozilo.registracija}</li>
|
||||
<li><strong>Trenutna kilometraža:</strong> {vozilo.trenutni_kilometri} km</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p>Nema dodijeljenog servisnog vozila.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
const OsnovniPodaciPutnogNaloga = ({ nalog }) => (
|
||||
<section>
|
||||
<h3>Osnovni podaci</h3>
|
||||
<p>Kupac: {nalog.klijent?.naziv}</p>
|
||||
<p>Opis: {nalog.opis_kvara}</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default function RadniNalogDisplay({ initialNalog }) {
|
||||
// 1. Sinkroniziraj store s prop-om
|
||||
useEffect(() => {
|
||||
if (initialNalog) {
|
||||
setNalogDetalji(initialNalog);
|
||||
}
|
||||
}, [initialNalog]);
|
||||
|
||||
const nalog = useStore($radniNalogDetalji);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
if (!nalog) return <div>Učitavanje...</div>;
|
||||
|
||||
const { vozilo } = nalog;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
<h1>Radni nalog: {nalog.broj_naloga}</h1>
|
||||
<p>Status: {nalog.status_display}</p>
|
||||
<button onClick={() => setIsEditing(true)}>Uredi</button>
|
||||
</header>
|
||||
|
||||
<VoziloInfo vozilo={vozilo} />
|
||||
|
||||
{isEditing && (
|
||||
<RadniNalogEditForm
|
||||
nalog={nalog}
|
||||
onSave={() => setIsEditing(false)}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OsnovniPodaciPutnogNaloga nalog={nalog} />
|
||||
|
||||
<Gallery images={nalog.slike || []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
002.FRONTEND/src/components/RadniNalogLista.jsx
Normal file
29
002.FRONTEND/src/components/RadniNalogLista.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/components/RadniNalogLista.jsx
|
||||
import { h } from 'preact';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { radniNalozi, setRadniNalozi } from '../stores/operativaStore';
|
||||
|
||||
export default function RadniNalogLista({ nalozi = [], limit = 5 }) {
|
||||
useEffect(() => {
|
||||
if (nalozi.length > 0) setRadniNalozi(nalozi);
|
||||
}, [nalozi]);
|
||||
|
||||
const podaciIzStora = useStore(radniNalozi);
|
||||
|
||||
if (!podaciIzStora.length) return <div>Učitavanje...</div>;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{podaciIzStora.slice(0, limit).map(n => (
|
||||
// KLJUČNA PROMJENA: li je ovdje glavni element, a ne div
|
||||
<li key={n.id} style={{ marginBottom: '10px' }}>
|
||||
{n.broj_naloga} -
|
||||
<a href={`/operativa/radni-nalozi/${n.id}`} style={{ marginLeft: '10px' }}>
|
||||
Pregled
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
22
002.FRONTEND/src/components/ToastContainer.jsx
Normal file
22
002.FRONTEND/src/components/ToastContainer.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { h } from 'preact';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $toasts } from '../stores/toastStore';
|
||||
|
||||
export default function ToastContainer() {
|
||||
const toasts = useStore($toasts);
|
||||
|
||||
return (
|
||||
<div class="fixed top-5 right-5 z-[9999] flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
class={`p-4 rounded-xl shadow-lg text-white font-bold transition-all ${
|
||||
toast.type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
002.FRONTEND/src/components/auth/AuthStatus.jsx
Normal file
36
002.FRONTEND/src/components/auth/AuthStatus.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// src/components/auth/AuthStatus.jsx
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { logoutUser } from '../../utils/users';
|
||||
import Button from '../Button.jsx';
|
||||
|
||||
export default function AuthStatus() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Provjeri localStorage pri učitavanju
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
setIsLoggedIn(!!token);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
// Opcionalno: dodaj event listener za promjene u localStorage
|
||||
window.addEventListener('storage', checkAuth);
|
||||
return () => window.removeEventListener('storage', checkAuth);
|
||||
}, []);
|
||||
|
||||
return isLoggedIn ? (
|
||||
<div onClick={logoutUser}>
|
||||
<Button variant="danger" class="!py-2 !px-4 uppercase text-[10px] tracking-widest italic">
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<a href="/login">
|
||||
<Button variant="primary" class="!py-2 !px-4 uppercase text-[10px] tracking-widest italic">
|
||||
Login
|
||||
</Button>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
28
002.FRONTEND/src/components/edit/EditNalogToggle.jsx
Normal file
28
002.FRONTEND/src/components/edit/EditNalogToggle.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// src/components/edit/EditNalogToggle.jsx
|
||||
// Ovo postaje višak
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import RadniNalogEditForm from './RadniNalogEditForm';
|
||||
import Button from '../Button';
|
||||
|
||||
export default function EditNalogToggle({ nalog }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'inline-block' }}>
|
||||
<Button
|
||||
label="Uredi radni nalog"
|
||||
onClick={() => setIsEditing(true)}
|
||||
variant="secondary"
|
||||
/>
|
||||
|
||||
{isEditing && (
|
||||
<RadniNalogEditForm
|
||||
nalog={nalog}
|
||||
onSave={() => { setIsEditing(false); window.location.reload(); }}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
002.FRONTEND/src/components/edit/ImageUpload.jsx
Normal file
53
002.FRONTEND/src/components/edit/ImageUpload.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { uploadSlikaRadniNalog } from '../../lib/api.js';
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
|
||||
export default function ImageUpload({ nalogId, onUploadSuccess }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const files = e.target.files; // Uzimamo sve odabrane datoteke
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
|
||||
// Ključno: dodajemo sve datoteke pod istim ključem 'slike'
|
||||
// Backend (Django) će ovo dohvatiti pomoću request.FILES.getlist('slike')
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('slika', files[i]);
|
||||
}
|
||||
|
||||
formData.append('radni_nalog', nalogId);
|
||||
|
||||
try {
|
||||
await uploadSlikaRadniNalog(formData);
|
||||
showToast("Slike uspješno učitane", "success");
|
||||
onUploadSuccess();
|
||||
} catch (err) {
|
||||
console.error("Greška pri uploadu:", err);
|
||||
showToast("Greška pri učitavanju", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
e.target.value = ''; // Resetiraj input nakon uploada
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="mt-4 border-t pt-4">
|
||||
<label class="block text-[10px] font-black uppercase text-gray-500 mb-2 italic">
|
||||
Dodaj fotografije (odaberi više)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
multiple // OVO JE KLJUČNO
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={loading}
|
||||
class="block w-full text-xs text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer"
|
||||
/>
|
||||
{loading && <p class="text-[10px] text-blue-600 mt-2 animate-pulse">Učitavanje u tijeku...</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
002.FRONTEND/src/components/edit/RadniNalogEditForm.jsx
Normal file
76
002.FRONTEND/src/components/edit/RadniNalogEditForm.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// src/components/edit/RadniNalogEditForm.jsx
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { patchRadniNalog } from '../../lib/api';
|
||||
import { setNalogDetalji } from '../../stores/operativaStore';
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
import Button from '../Button';
|
||||
import ImageUpload from './ImageUpload';
|
||||
|
||||
export default function RadniNalogEditForm({ nalog, onSave, onCancel }) {
|
||||
const [formData, setFormData] = useState({ opis_kvara: nalog.opis_kvara || '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateNalog = async (data) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const azuriraniNalog = await patchRadniNalog(nalog.id, data);
|
||||
if (azuriraniNalog) {
|
||||
setNalogDetalji({ ...nalog, ...azuriraniNalog });
|
||||
showToast("Nalog uspješno ažuriran", "success");
|
||||
onSave();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Greška:", error);
|
||||
showToast("Došlo je do greške", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Logika za završavanje naloga
|
||||
const handleZavrsi = () => {
|
||||
if (confirm("Jeste li sigurni da želite označiti nalog kao ZAVRŠEN?")) {
|
||||
updateNalog({ status: 'ZAVRSENO' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
updateNalog(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-3xl w-full max-w-lg shadow-2xl">
|
||||
<h2 class="text-2xl font-black mb-6 uppercase">Uredi nalog #{nalog.broj_naloga}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} class="space-y-4">
|
||||
<textarea
|
||||
class="w-full p-4 rounded-xl border bg-gray-50 dark:bg-gray-900"
|
||||
value={formData.opis_kvara}
|
||||
onInput={(e) => setFormData({ ...formData, opis_kvara: e.target.value })}
|
||||
/>
|
||||
|
||||
<ImageUpload nalogId={nalog.id} />
|
||||
|
||||
<div class="flex flex-col gap-3 pt-4">
|
||||
<Button label="Spremi izmjene" type="submit" loading={loading} />
|
||||
|
||||
{/* Gumb za završavanje - prikazuje se samo ako nalog već nije završen */}
|
||||
{nalog.status !== 'ZAVRSENO' && (
|
||||
<Button
|
||||
label="Završi radni nalog"
|
||||
variant="success"
|
||||
onClick={handleZavrsi}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button label="Odustani" variant="secondary" onClick={onCancel} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
002.FRONTEND/src/components/edit/RadniNalogEditorStatus.jsx
Normal file
31
002.FRONTEND/src/components/edit/RadniNalogEditorStatus.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// src/components/edit/RadniNalogEditorStatus.jsx
|
||||
// Ovo postaje višak
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { patchNalog } from '../../lib/api';
|
||||
import Button from '../Button'; // Import nove komponente
|
||||
|
||||
export default function RadniNalogEditorStatus({ nalogId, pocetniStatus }) {
|
||||
const [status, setStatus] = useState(pocetniStatus);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const azurirajStatus = async () => {
|
||||
setLoading(true);
|
||||
const rezultat = await patchNalog(nalogId, { status: 'ZAVRSENO' });
|
||||
setLoading(false);
|
||||
|
||||
if (rezultat) {
|
||||
setStatus('ZAVRSENO');
|
||||
window.showToast?.("Status ažuriran", "success");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={status === 'ZAVRSENO' ? 'Završen' : 'Završi radni nalog'}
|
||||
onClick={azurirajStatus}
|
||||
loading={loading}
|
||||
disabled={status === 'ZAVRSENO'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
158
002.FRONTEND/src/components/novi/NoviRadniNalogForm.jsx
Normal file
158
002.FRONTEND/src/components/novi/NoviRadniNalogForm.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
// src/components/operativa/novi/NoviRadniNalogForm.jsx
|
||||
import { h } from "preact";
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import { navigate } from "astro:transitions/client";
|
||||
import { createNalog, getPutniNalozi, getStrojeviData, fetchKupciData } from "../../lib/api";
|
||||
import { showToast } from "../../stores/toastStore";
|
||||
import Button from "../Button.jsx";
|
||||
|
||||
export default function NoviRadniNalogForm() {
|
||||
const [putniNalogMode, setPutniNalogMode] = useState('none');
|
||||
const [putniNalozi, setPutniNalozi] = useState([]);
|
||||
const [selectedPutniId, setSelectedPutniId] = useState('');
|
||||
|
||||
const [klijenti, setKlijenti] = useState([]);
|
||||
const [klijentId, setKlijentId] = useState('');
|
||||
const [strojevi, setStrojevi] = useState([]);
|
||||
const [selectedStrojId, setSelectedStrojId] = useState('');
|
||||
|
||||
const [isFetchingNaloge, setIsFetchingNaloge] = useState(false);
|
||||
const [isFetchingStrojevi, setIsFetchingStrojevi] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Dohvati listu klijenata pri učitavanju forme
|
||||
useEffect(() => {
|
||||
// Pretpostavljam da imaš funkciju getKlijenti() u api.js
|
||||
fetchKupciData().then(data => setKlijenti(data));
|
||||
}, []);
|
||||
|
||||
// Dohvat Putnih naloga
|
||||
useEffect(() => {
|
||||
if (putniNalogMode === 'existing') {
|
||||
setIsFetchingNaloge(true);
|
||||
getPutniNalozi()
|
||||
.then(data => setPutniNalozi(data))
|
||||
.finally(() => setIsFetchingNaloge(false));
|
||||
}
|
||||
}, [putniNalogMode]);
|
||||
|
||||
// 2. Dohvat Strojeva ovisno o klijentu
|
||||
useEffect(() => {
|
||||
// Popravljeno: slušamo klijentId, a ne klijenti (koji je niz)
|
||||
if (klijentId) {
|
||||
setIsFetchingStrojevi(true);
|
||||
getStrojeviData(klijentId)
|
||||
.then(data => setStrojevi(data))
|
||||
.finally(() => setIsFetchingStrojevi(false));
|
||||
} else {
|
||||
setStrojevi([]);
|
||||
setSelectedStrojId('');
|
||||
}
|
||||
}, [klijentId]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
// Logika za povezivanje putnog naloga koju backend view sada očekuje
|
||||
if (putniNalogMode === 'existing' && selectedPutniId) {
|
||||
formData.append('putni_nalog', selectedPutniId);
|
||||
} else if (putniNalogMode === 'new') {
|
||||
formData.append('kreiraj_putni', 'true');
|
||||
}
|
||||
|
||||
const result = await createNalog(formData);
|
||||
|
||||
if (result) {
|
||||
showToast("Radni nalog uspješno kreiran!", "success");
|
||||
navigate('/operativa/radni-nalozi');
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="space-y-6">
|
||||
{/* Ovdje idu tvoja polja za nalog (klijent, stroj, opis...) */}
|
||||
<div>
|
||||
<label class="block text-gray-300">Klijent:</label>
|
||||
<select
|
||||
name="klijent"
|
||||
required
|
||||
class="w-full p-3 bg-gray-700 rounded text-white"
|
||||
onChange={(e) => setKlijentId(e.target.value)}
|
||||
>
|
||||
<option value="">-- Odaberite klijenta --</option>
|
||||
|
||||
{/* OVDJE PROVJERI: */}
|
||||
{klijenti.map(k => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.naziv}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-gray-300">Stroj (ID):</label>
|
||||
<select
|
||||
name="stroj"
|
||||
required
|
||||
class="w-full p-3 bg-gray-700 rounded text-white"
|
||||
onChange={(e) => setSelectedStrojId(e.target.value)}
|
||||
>
|
||||
<option value="">-- Odaberite stroj --</option>
|
||||
{strojevi.map(stroj => (
|
||||
<option key={stroj.id} value={stroj.id}>
|
||||
{stroj.naziv}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-gray-300">Opis kvara:</label>
|
||||
<textarea name="opis_kvara" required class="w-full p-3 bg-gray-700 rounded"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 border-t pt-4">
|
||||
<label class="block mb-2 font-bold text-gray-300">Putni nalog:</label>
|
||||
<select
|
||||
onChange={(e) => setPutniNalogMode(e.target.value)}
|
||||
class="w-full p-3 rounded bg-gray-700 text-white border border-gray-600"
|
||||
>
|
||||
<option value="none">Bez putnog naloga</option>
|
||||
<option value="new">Kreiraj novi prazni putni nalog</option>
|
||||
<option value="existing">Poveži na postojeći</option>
|
||||
</select>
|
||||
|
||||
{putniNalogMode === 'existing' && (
|
||||
<select
|
||||
value={selectedPutniId}
|
||||
onChange={(e) => setSelectedPutniId(e.target.value)}
|
||||
disabled={isFetchingNaloge}
|
||||
class={`w-full p-3 mt-3 rounded bg-gray-700 text-white border border-gray-500 ${isFetchingNaloge ? 'opacity-50 cursor-wait' : ''}`}
|
||||
>
|
||||
{isFetchingNaloge ? (
|
||||
<option>Učitavanje putnih naloga...</option>
|
||||
) : (
|
||||
<option value="">-- Odaberite postojeći putni nalog --</option>
|
||||
)}
|
||||
|
||||
{!isFetchingNaloge && putniNalozi.map(pn => (
|
||||
<option key={pn.id} value={pn.id}>
|
||||
{pn.broj_naloga} | {pn.status} | {pn.mjesto_odredista}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" loading={loading} variant="primary" className="w-full">
|
||||
Kreiraj Radni Nalog
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
142
002.FRONTEND/src/data/site.json
Normal file
142
002.FRONTEND/src/data/site.json
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"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": {
|
||||
"auth": {
|
||||
"login": "api/token/",
|
||||
"refresh": "api/token/refresh/",
|
||||
"me": "api/users/me/"
|
||||
},
|
||||
"users": {
|
||||
"list": "api/users/",
|
||||
"terminal": "api/users/{pk}/terminal/"
|
||||
},
|
||||
"kupci": {
|
||||
"list": "api/kupci/svi/",
|
||||
"detail": "api/kupci/svi/{pk}/"
|
||||
},
|
||||
"fleet": {
|
||||
"vozila": "api/fleet/vozila/",
|
||||
"strojevi": "api/fleet/strojevi/",
|
||||
"strojDetalji": "api/fleet/strojevi/{pk}/"
|
||||
},
|
||||
"operativa": {
|
||||
"radniNalozi": "api/operativa/radni-nalozi/",
|
||||
"radniNalogDetalji": "api/operativa/radni-nalozi/{pk}/",
|
||||
"sljedeciBroj": "api/operativa/radni-nalozi/sljedeci-broj/",
|
||||
"putniNalozi": "api/operativa/putni-nalozi/",
|
||||
"upload_slika": "api/operativa/radni-nalozi-slike/"
|
||||
},
|
||||
"kalendar": {
|
||||
"mojRaspored": "api/kalendar/moj-raspored/",
|
||||
"dogadaji": "api/kalendar/dogadaji/"
|
||||
}
|
||||
},
|
||||
"status_config": {
|
||||
"u_radu": { "class": "bg-yellow-500 animate-pulse", "label": "U radu" },
|
||||
"planirano": { "class": "bg-blue-500", "label": "Planirano" },
|
||||
"aktivan": { "class": "bg-blue-500", "label": "Aktivan" },
|
||||
"zavrseno": { "class": "bg-emerald-500", "label": "Završeno" },
|
||||
"naplaceno": { "class": "bg-gray-500 opacity-50", "label": "Naplaćeno" },
|
||||
"servis": { "class": "bg-red-500 animate-pulse", "label": "Na servisu" },
|
||||
"hitno": { "class": "bg-red-500 animate-pulse", "label": "HITNO" }
|
||||
}
|
||||
}
|
||||
91
002.FRONTEND/src/layouts/Layout.astro
Normal file
91
002.FRONTEND/src/layouts/Layout.astro
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
import siteConfig from '../data/site.json';
|
||||
import ToastContainer from '../components/ToastContainer.jsx';
|
||||
import AuthStatus from '../components/auth/AuthStatus.jsx';
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<ClientRouter />
|
||||
<title>{title} | {siteConfig.title}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #121212;
|
||||
--text-color: #e0e0e0;
|
||||
--accent-color: #3b82f6;
|
||||
--nav-bg: #1e1e1e;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
nav {
|
||||
background-color: var(--nav-bg);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
nav a {
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
nav a:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<a href="/" style="font-weight: bold;">{siteConfig.title}</a>
|
||||
{siteConfig.navigation.filter(item => item.welcomeHeaderDisplay).map(item => (
|
||||
<a href={item.url}>{item.name}</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<ToastContainer client:only="preact" />
|
||||
|
||||
<header>
|
||||
<AuthStatus client:only="preact" />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© {new Date().getFullYear()} {siteConfig.author}
|
||||
</footer>
|
||||
|
||||
<div id="toast-container" style="position: fixed; bottom: 20px; right: 20px;"></div>
|
||||
</body>
|
||||
</html>
|
||||
110
002.FRONTEND/src/lib/api.js
Normal file
110
002.FRONTEND/src/lib/api.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// src/lib/api.js
|
||||
import { getApiUrl, getAuthHeaders, handleResponse } from '../utils/api';
|
||||
|
||||
// --- API METODE ---
|
||||
|
||||
export async function getRadniNalozi(params = {}) {
|
||||
const url = getApiUrl('operativa.radniNalozi', params);
|
||||
if (!url) return [];
|
||||
|
||||
const options = { method: 'GET', headers: getAuthHeaders() };
|
||||
const res = await fetch(url, options);
|
||||
return await handleResponse(res, { url, options }) || [];
|
||||
}
|
||||
|
||||
export async function getRadniNalogDetalji(id) {
|
||||
const url = getApiUrl('operativa.radniNalogDetalji', { pk: id });
|
||||
if (!url) return null;
|
||||
|
||||
const options = { method: 'GET', headers: getAuthHeaders() };
|
||||
const res = await fetch(url, options);
|
||||
return await handleResponse(res, { url, options });
|
||||
}
|
||||
|
||||
export async function getKupci() {
|
||||
const url = getApiUrl('kupci.list');
|
||||
if (!url) return [];
|
||||
|
||||
const res = await fetch(url, { method: 'GET', headers: getAuthHeaders() });
|
||||
const data = await handleResponse(res);
|
||||
return Array.isArray(data) ? data : (data?.results || []);
|
||||
}
|
||||
|
||||
export async function getVozila() {
|
||||
const url = getApiUrl('fleet.vozila');
|
||||
if (!url) return [];
|
||||
|
||||
const res = await fetch(url, { method: 'GET', headers: getAuthHeaders() });
|
||||
return await handleResponse(res) || [];
|
||||
}
|
||||
|
||||
export async function getStrojevi(vlasnikId = null) {
|
||||
const url = getApiUrl('fleet.strojevi') + (vlasnikId ? `?vlasnik=${vlasnikId}` : '');
|
||||
const res = await fetch(url, { method: 'GET', headers: getAuthHeaders() });
|
||||
return await handleResponse(res) || [];
|
||||
}
|
||||
|
||||
export async function getMojRaspored() {
|
||||
const url = getApiUrl('kalendar.mojRaspored');
|
||||
const res = await fetch(url, { method: 'GET', headers: getAuthHeaders() });
|
||||
return await handleResponse(res) || [];
|
||||
}
|
||||
|
||||
export async function getServiseri() {
|
||||
const url = getApiUrl('users.list');
|
||||
const res = await fetch(url, { method: 'GET', headers: getAuthHeaders() });
|
||||
const data = await handleResponse(res);
|
||||
return Array.isArray(data) ? data : (data?.results || []);
|
||||
}
|
||||
|
||||
export async function getDashboardData() {
|
||||
// Paralelni dohvat za brzi prikaz na kontrolnoj ploči
|
||||
const [resV, resN] = await Promise.all([
|
||||
fetch(getApiUrl('fleet.vozila'), { method: 'GET', headers: getAuthHeaders() }),
|
||||
fetch(getApiUrl('operativa.radniNalozi'), { method: 'GET', headers: getAuthHeaders() })
|
||||
]);
|
||||
|
||||
return {
|
||||
vozila: await handleResponse(resV) || [],
|
||||
nalozi: await handleResponse(resN) || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch metoda za ažuriranje dijela radnog naloga
|
||||
*/
|
||||
export async function patchRadniNalog(id, data) {
|
||||
const url = getApiUrl('operativa.radniNalogDetalji', { pk: id });
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return await handleResponse(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload slika za radni nalog
|
||||
*/
|
||||
export async function uploadSlikaRadniNalog(formData) {
|
||||
const url = getApiUrl('operativa.upload_slika');
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
// VAŽNO: Kada koristiš FormData, NE smiješ postavljati Content-Type header.
|
||||
// Browser će ga automatski postaviti s ispravnim 'boundary' parametrom.
|
||||
headers: getAuthHeaders(formData),
|
||||
body: formData
|
||||
});
|
||||
|
||||
return await handleResponse(res);
|
||||
}
|
||||
15
002.FRONTEND/src/pages/fleet/strojevi.astro
Normal file
15
002.FRONTEND/src/pages/fleet/strojevi.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import FleetStrojeviLista from '../../components/FleetStrojeviLista.jsx';
|
||||
import { getStrojevi } from '../../lib/api'; // Ovdje koristi getStrojevi
|
||||
|
||||
const strojeviData = await getStrojevi(); // I ovdje koristi getStrojevi
|
||||
---
|
||||
|
||||
<Layout title="Flota strojeva">
|
||||
<main class="p-8">
|
||||
<h1 class="text-3xl font-black mb-6 uppercase">Pregled strojeva</h1>
|
||||
|
||||
<FleetStrojeviLista client:load initialData={strojeviData} />
|
||||
</main>
|
||||
</Layout>
|
||||
16
002.FRONTEND/src/pages/fleet/vozila.astro
Normal file
16
002.FRONTEND/src/pages/fleet/vozila.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
// src/pages/fleet/vozila.astro
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import FleetVozilaLista from '../../components/FleetVozilaLista.jsx';
|
||||
import { getVozila } from '../../lib/api';
|
||||
|
||||
const vozilaData = await getVozila();
|
||||
---
|
||||
|
||||
<Layout title="Vozni park">
|
||||
<main class="p-8">
|
||||
<h1 class="text-3xl font-black mb-6 uppercase">Vozni park</h1>
|
||||
|
||||
<FleetVozilaLista client:load initialData={vozilaData} />
|
||||
</main>
|
||||
</Layout>
|
||||
45
002.FRONTEND/src/pages/index.astro
Normal file
45
002.FRONTEND/src/pages/index.astro
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
// src/pages/index.astro
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import siteConfig from "../data/site.json";
|
||||
import { getRadniNalozi, getVozila } from "../lib/api";
|
||||
import RadniNalogLista from '../components/RadniNalogLista.jsx';
|
||||
|
||||
const nalozi = await getRadniNalozi();
|
||||
const vozila = await getVozila();
|
||||
---
|
||||
|
||||
<Layout title="Dashboard">
|
||||
<header>
|
||||
<h1>{siteConfig.navigation[0].welcomeHeaderTextH1} {siteConfig.navigation[0].welcomeHeaderTextH1dodatno}</h1>
|
||||
<p>{siteConfig.navigation[0].welcomeHeaderPodnaslov}</p>
|
||||
</header>
|
||||
|
||||
<main class="dashboard-grid">
|
||||
<section>
|
||||
<h2>Radni nalozi</h2>
|
||||
<RadniNalogLista client:load nalozi={nalozi} limit={5} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Stanje flote</h2>
|
||||
<div>
|
||||
<p>Ukupno vozila: {vozila.length}</p>
|
||||
<ul>
|
||||
{vozila.map((vozilo) => (
|
||||
<li>{vozilo.marka} {vozilo.model} ({vozilo.registracija})</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
</style>
|
||||
8
002.FRONTEND/src/pages/kupci/svi.astro
Normal file
8
002.FRONTEND/src/pages/kupci/svi.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
---
|
||||
|
||||
<Layout title="Radni nalozi">
|
||||
<h1>Kupci</h1>
|
||||
<p>Pregled kupaca.</p>
|
||||
</Layout>
|
||||
14
002.FRONTEND/src/pages/login.astro
Normal file
14
002.FRONTEND/src/pages/login.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
// /src/pages/login.astro
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Login from "../components/Login.jsx";
|
||||
|
||||
// Opcionalno: Ako je korisnik već prijavljen, odmah ga preusmjeri na dashboard
|
||||
// Ovo se izvršava na serveru (SSR)
|
||||
---
|
||||
|
||||
<Layout title="Prijava u ServisLog">
|
||||
<div class="flex items-center justify-center min-h-[80vh]">
|
||||
<Login client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
19
002.FRONTEND/src/pages/operativa/radni-nalozi/[id].astro
Normal file
19
002.FRONTEND/src/pages/operativa/radni-nalozi/[id].astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
// src/pages/operativa/radni-nalozi/[id].astro
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import { getRadniNalogDetalji } from "../../../lib/api.js";
|
||||
import RadniNalogDisplay from "../../../components/RadniNalogDisplay.jsx";
|
||||
|
||||
const { id } = Astro.params;
|
||||
const nalog = await getRadniNalogDetalji(id);
|
||||
|
||||
if (!nalog) return Astro.redirect('/operativa/radni-nalozi');
|
||||
---
|
||||
|
||||
<Layout title={`Radni nalog ${nalog.broj_naloga}`}>
|
||||
<main>
|
||||
<a href="/operativa/radni-nalozi">← Natrag na listu</a>
|
||||
|
||||
<RadniNalogDisplay client:load initialNalog={nalog} />
|
||||
</main>
|
||||
</Layout>
|
||||
15
002.FRONTEND/src/pages/operativa/radni-nalozi/index.astro
Normal file
15
002.FRONTEND/src/pages/operativa/radni-nalozi/index.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
// src/pages/operativa/radni-nalozi/index.astro
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import RadniNalogLista from "../../../components/RadniNalogLista.jsx";
|
||||
import { getRadniNalozi } from "../../../lib/api.js";
|
||||
|
||||
const nalozi = await getRadniNalozi();
|
||||
---
|
||||
|
||||
<Layout title="Radni nalozi">
|
||||
<h1>Operativni radni nalozi</h1>
|
||||
<p>Pregled i upravljanje servisnim operacijama.</p>
|
||||
|
||||
<RadniNalogLista client:load nalozi={nalozi} limit={20} />
|
||||
</Layout>
|
||||
15
002.FRONTEND/src/pages/operativa/radni-nalozi/novi.astro
Normal file
15
002.FRONTEND/src/pages/operativa/radni-nalozi/novi.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
// src/pages/operativa/radni-nalozi/novi.astro
|
||||
import Layout from '../../../layouts/Layout.astro';
|
||||
import NoviRadniNalogForm from '../../../components/novi/NoviRadniNalogForm.jsx';
|
||||
---
|
||||
|
||||
<Layout title="Novi radni nalog">
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
<h1 class="text-3xl font-bold mb-8">Kreiranje novog radnog naloga</h1>
|
||||
|
||||
<div class="bg-gray-800 p-8 rounded-2xl shadow-xl">
|
||||
<NoviRadniNalogForm client:load />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
8
002.FRONTEND/src/stores/appState.js
Normal file
8
002.FRONTEND/src/stores/appState.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/stores/appState.js
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const radniNalozi = atom([]);
|
||||
export const vozila = atom([]);
|
||||
export const isNalogModalOpen = atom(false);
|
||||
export const activeUserRole = atom('SERVISER');
|
||||
export const isLoading = atom(false);
|
||||
9
002.FRONTEND/src/stores/fleetStore.js
Normal file
9
002.FRONTEND/src/stores/fleetStore.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// src/stores/fleetStore.js
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const vozila = atom([]);
|
||||
export const strojevi = atom([]);
|
||||
export const activeStroj = atom(null); // Za detalje stroja
|
||||
|
||||
export const setVozila = (data) => vozila.set(data);
|
||||
export const setStrojevi = (data) => strojevi.set(data);
|
||||
10
002.FRONTEND/src/stores/galleryStore.js
Normal file
10
002.FRONTEND/src/stores/galleryStore.js
Normal 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);
|
||||
};
|
||||
8
002.FRONTEND/src/stores/kalendarStore.js
Normal file
8
002.FRONTEND/src/stores/kalendarStore.js
Normal 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);
|
||||
6
002.FRONTEND/src/stores/kupciStore.js
Normal file
6
002.FRONTEND/src/stores/kupciStore.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/stores/kupciStore.js
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const kupci = atom([]);
|
||||
|
||||
export const setKupci = (data) => kupci.set(data);
|
||||
13
002.FRONTEND/src/stores/operativaStore.js
Normal file
13
002.FRONTEND/src/stores/operativaStore.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// src/stores/operativaStore.js
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const radniNalozi = atom([]);
|
||||
export const putniNalozi = atom([]);
|
||||
export const serviseri = atom([]);
|
||||
export const activeNalogId = atom(null);
|
||||
export const $radniNalogDetalji = atom(null); // DODANO: Store za detalje jednog naloga
|
||||
|
||||
export const setRadniNalozi = (data) => radniNalozi.set(data);
|
||||
export const setPutniNalozi = (data) => putniNalozi.set(data);
|
||||
export const setServiseri = (data) => serviseri.set(data);
|
||||
export const setNalogDetalji = (data) => $radniNalogDetalji.set(data); // DODANO
|
||||
23
002.FRONTEND/src/stores/rootStore.js
Normal file
23
002.FRONTEND/src/stores/rootStore.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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';
|
||||
|
||||
export function resetAllStores() {
|
||||
radniNalozi.set([]);
|
||||
putniNalozi.set([]);
|
||||
serviseri.set([]);
|
||||
activeNalogId.set(null);
|
||||
vozila.set([]);
|
||||
strojevi.set([]);
|
||||
kupci.set([]);
|
||||
mojRaspored.set([]);
|
||||
kalendarDogadaji.set([]);
|
||||
$galleryImages.set([]);
|
||||
$toasts.set([]);
|
||||
|
||||
console.log("Sustav je resetiran: svi podaci u memoriji su očišćeni.");
|
||||
}
|
||||
26
002.FRONTEND/src/stores/toastStore.js
Normal file
26
002.FRONTEND/src/stores/toastStore.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/stores/toastStore.js
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const $toasts = atom([]);
|
||||
|
||||
/**
|
||||
* Prikazuje toast obavijest
|
||||
* @param {string} message - Poruka za prikaz
|
||||
* @param {'success' | 'error' | 'info'} type - Tip obavijesti
|
||||
*/
|
||||
export function showToast(message, type = 'success') {
|
||||
const id = Date.now();
|
||||
$toasts.set([...$toasts.get(), { id, message, type }]);
|
||||
|
||||
// Automatsko brisanje nakon 4 sekunde (malo duže za čitanje)
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ručno uklanjanje obavijesti
|
||||
*/
|
||||
export function removeToast(id) {
|
||||
$toasts.set($toasts.get().filter(t => t.id !== id));
|
||||
}
|
||||
72
002.FRONTEND/src/utils/api.js
Normal file
72
002.FRONTEND/src/utils/api.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// src/utils/api.js
|
||||
import siteConfig from "../data/site.json";
|
||||
import { showToast } from "../stores/toastStore";
|
||||
import { refreshAuthToken } from "../utils/users";
|
||||
|
||||
/**
|
||||
* Resolver za API putanje
|
||||
*/
|
||||
export const getApiUrl = (key, params = {}) => {
|
||||
if (!key || typeof key !== 'string') return null;
|
||||
|
||||
const [module, action] = key.split('.');
|
||||
const endpoint = siteConfig.api_endpoints?.[module]?.[action];
|
||||
|
||||
if (!endpoint) {
|
||||
console.error(`[API Error] Endpoint ${key} nije definiran u site.json`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Zamjena parametara (koristimo reduce za čišći kod)
|
||||
const path = Object.entries(params).reduce(
|
||||
(acc, [key, val]) => acc.replace(`{${key}}`, String(val)),
|
||||
endpoint
|
||||
);
|
||||
|
||||
// 2. Određivanje baze
|
||||
const isServer = typeof window === 'undefined';
|
||||
const baseUrl = isServer ? (import.meta.env.PUBLIC_API_URL || '').replace(/\/$/, '') : '';
|
||||
|
||||
// 3. Spajanje putanje uz uklanjanje duplih kosih crta (robustniji regex)
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
return `${baseUrl}${cleanPath}`.replace(/\/+/g, '/').replace(':/', '://');
|
||||
};
|
||||
|
||||
/**
|
||||
* Dohvaća token i postavlja Headere
|
||||
*/
|
||||
export function getAuthHeaders(bodyData = {}, ssrToken = null) {
|
||||
const headers = {};
|
||||
let token = ssrToken || (typeof window !== 'undefined' ? localStorage.getItem('access_token') : null);
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (!(bodyData instanceof FormData)) headers['Content-Type'] = 'application/json';
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizirana obrada odgovora
|
||||
*/
|
||||
export async function handleResponse(res, originalRequest = null) {
|
||||
if (res.status === 401 && originalRequest) {
|
||||
const newAccessToken = await refreshAuthToken();
|
||||
if (newAccessToken) {
|
||||
const newOptions = {
|
||||
...originalRequest.options,
|
||||
headers: { ...originalRequest.options.headers, 'Authorization': `Bearer ${newAccessToken}` }
|
||||
};
|
||||
const retryRes = await fetch(originalRequest.url, newOptions);
|
||||
return await handleResponse(retryRes, originalRequest);
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
console.error("--- DJANGO VALIDATION ERROR ---");
|
||||
console.error(JSON.stringify(errorData, null, 2));
|
||||
showToast(errorData.detail || "Greška pri sinkronizaciji podataka.", "error");
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data ?? {};
|
||||
}
|
||||
35
002.FRONTEND/src/utils/ui.js
Normal file
35
002.FRONTEND/src/utils/ui.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// src/utils/ui.js
|
||||
import siteConfig from "../data/site.json";
|
||||
|
||||
/**
|
||||
* Vraća set Tailwind klasa za specifičnu temu
|
||||
*/
|
||||
export function getThemeClasses(tema = "indigo") {
|
||||
// Možeš ovo izvući u site.json ako želiš globalno mijenjati boje
|
||||
const themes = {
|
||||
indigo: { bg: "bg-indigo-50", bgDark: "dark:bg-indigo-900/20", text: "text-indigo-600", border: "border-indigo-100", shadow: "shadow-indigo-500/5", hover: "hover:bg-indigo-50/30", 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", border: "border-blue-100", shadow: "shadow-blue-500/5", hover: "hover:bg-blue-50/30", 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", border: "border-emerald-100", shadow: "shadow-emerald-500/5", hover: "hover:bg-emerald-50/30", 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", border: "border-amber-100", shadow: "shadow-amber-500/5", hover: "hover:bg-amber-50/30", 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", border: "border-red-100", shadow: "shadow-red-500/5", hover: "hover:bg-red-50/30", icon: "group-hover:bg-red-600 group-hover:text-white" }
|
||||
};
|
||||
return themes[tema] || themes.indigo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dinamički dohvat konfiguracije statusa iz site.json
|
||||
*/
|
||||
export function getStatusColorClass(status) {
|
||||
const s = String(status ?? '').toLowerCase();
|
||||
const config = siteConfig.status_config?.[s];
|
||||
return config?.class ?? 'bg-gray-400';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretvara tehnički status u čitljiv format
|
||||
*/
|
||||
export function formatStatus(status) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
const config = siteConfig.status_config?.[s];
|
||||
return config?.label ?? 'Nepoznato';
|
||||
}
|
||||
86
002.FRONTEND/src/utils/users.js
Normal file
86
002.FRONTEND/src/utils/users.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// src/utils/users.js
|
||||
import { getApiUrl } from '../utils/api';
|
||||
import { showToast } from '../stores/toastStore';
|
||||
import { navigate } from 'astro:transitions/client';
|
||||
import { resetAllStores } from "../stores/rootStore";
|
||||
|
||||
/**
|
||||
* Prijava korisnika i pohrana JWT tokena
|
||||
*/
|
||||
export async function login(email, password) {
|
||||
try {
|
||||
const url = getApiUrl('auth.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);
|
||||
|
||||
showToast("Dobrodošli u sustav!", "success");
|
||||
|
||||
// SPA navigacija na dashboard
|
||||
navigate('/');
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
console.error("Login Error:", e);
|
||||
showToast(`Greška pri prijavi: ${e.message}`, "error");
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Odjava korisnika i čišćenje lokalne pohrane (SPA verzija)
|
||||
*/
|
||||
export function logoutUser() {
|
||||
// 1. Resetiranje stanja u store-ovima
|
||||
if (typeof resetAllStores === 'function') {
|
||||
resetAllStores();
|
||||
}
|
||||
|
||||
// 2. Čišćenje tokena
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
|
||||
// 3. Vizualna obavijest
|
||||
showToast("Odjava uspješna. Vidimo se!", "success");
|
||||
|
||||
// 4. SPA navigacija bez potpunog osvježavanja stranice
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Osvježavanje tokena
|
||||
*/
|
||||
export async function refreshAuthToken() {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) return null;
|
||||
|
||||
try {
|
||||
const url = getApiUrl('auth.refresh');
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh: refreshToken })
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Osvježavanje nije uspjelo");
|
||||
|
||||
const data = await res.json();
|
||||
localStorage.setItem('access_token', data.access);
|
||||
return data.access;
|
||||
} catch (e) {
|
||||
console.error("Refresh token error:", e);
|
||||
logoutUser(); // Automatska odjava ako refresh ne prođe
|
||||
return null;
|
||||
}
|
||||
}
|
||||
9
002.FRONTEND/tsconfig.json
Normal file
9
002.FRONTEND/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user