izradena poslovna logika

This commit is contained in:
2026-05-25 04:25:34 +00:00
parent 668195e642
commit e0a3dea68a
127 changed files with 2417 additions and 6509 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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'}
/>
);
}

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

View 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" }
}
}

View 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>
&copy; {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
View 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);
}

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,8 @@
---
import Layout from "../../layouts/Layout.astro";
---
<Layout title="Radni nalozi">
<h1>Kupci</h1>
<p>Pregled kupaca.</p>
</Layout>

View 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>

View 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">&larr; Natrag na listu</a>
<RadniNalogDisplay client:load initialNalog={nalog} />
</main>
</Layout>

View 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>

View 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>

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

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

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,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

View 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.");
}

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

View 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 ?? {};
}

View 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';
}

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