prvi kod za live uporabu
Some checks failed
ERP CI Pipeline / test (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-09 21:21:17 +02:00
parent 857bb65d52
commit 5d39e048ba
239 changed files with 25151 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import { $accessToken, $authReady, hydrateAuthFromStorage, logout } from '../stores/authStore';
import { showToast } from '../stores/toastStore';
export default function AuthWidget() {
const token = useStore($accessToken);
const authReady = useStore($authReady);
// hasMounted: SSR uvijek renderira "Prijava" link — identičan SSR i client DOM
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
// AuthWidget je autonoman — ne oslanja se na to da netko drugi (FleetDashboardShell)
// pozove hydrateAuthFromStorage(). Idempotentna je (authHydrated flag unutar store-a
// osigurava da se localStorage čita samo jednom, čak i ako se poziva višestruko).
hydrateAuthFromStorage();
}, []);
// Dok SSR ili auth hydration nisu gotovi — prikaži neutralni "Prijava" link
// kako ne bi došlo do layout shifta ili hydration mismatch-a
if (!hasMounted || !authReady) {
return (
<div className="flex items-center gap-2">
<a
href="/login"
className="rounded-md border border-border-hairline px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
Prijava
</a>
</div>
);
}
return (
<div className="flex items-center gap-2">
{!token ? (
<a
href="/login"
className="rounded-md border border-border-hairline px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
Prijava
</a>
) : (
<>
<button
type="button"
onClick={() => {
logout();
showToast('Uspješno ste odjavljeni.', 'success', 2500);
window.location.href = '/login';
}}
className="rounded-md border border-border-hairline px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
Odjava
</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
import NotificationBell from './NotificationBell';
import ThemeToggle from './ui/ThemeToggle';
import AuthWidget from './AuthWidget';
import UserDisplay from './ui/UserDisplay';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { hydrateAuthFromStorage } from '../stores/authStore';
const ITEMS = [
{ id: 'dashboard', label: 'Dashboard', href: '/' },
{ id: 'work-orders', label: 'Putni nalozi', href: '/putni-nalozi' },
{ id: 'service-records', label: 'Servisni zapisi', href: '/servisni-zapisi' },
{ id: 'vehicles', label: 'Vozila', href: '/vehicles' },
{ id: 'clients', label: 'Klijenti', href: '/clients' },
];
export default function Navbar({ minimal = false }) {
const [pathname, setPathname] = useState('/');
const normalizedPath = useMemo(() => {
const value = String(pathname || '/');
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
return value;
}, [pathname]);
useEffect(() => {
const updatePath = () => setPathname(window.location.pathname || '/');
updatePath();
window.addEventListener('astro:page-load', updatePath);
// Hidratira auth iz storage pri prvi puta učitavanja Navbar-a
hydrateAuthFromStorage();
return () => window.removeEventListener('astro:page-load', updatePath);
}, []);
const currentItem = useMemo(
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
[normalizedPath]
);
return (
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-2">
{/* Logo + korisnik */}
<a href="/" className="flex flex-col hover:opacity-80">
<span className="text-base font-bold tracking-tight text-text-main">ERP</span>
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
</a>
{/* Navigacijski linkovi */}
<ul className="hidden items-center gap-1 text-sm sm:flex">
{ITEMS.map((item) => (
<li key={item.id}>
<a
href={item.href}
className={
normalizedPath === item.href
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
}
>
{item.label}
</a>
</li>
))}
</ul>
{/* Desna strana */}
<div className="flex items-center gap-2">
<details className="relative sm:hidden">
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
{currentItem?.label || 'Izbornik'}
</summary>
<div className="absolute right-0 mt-2 min-w-44 rounded-lg border border-border-hairline bg-canvas-elevated shadow-lg">
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
Navigacija
</div>
<ul className="py-1">
{ITEMS.map((item) => (
<li key={`mobile-${item.id}`}>
<a
href={item.href}
className={
normalizedPath === item.href
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
}
>
{item.label}
</a>
</li>
))}
</ul>
</div>
</details>
<ThemeToggle />
<NotificationBell />
<AuthWidget />
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,135 @@
import { useStore } from '@nanostores/preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import {
$isRealtimeConnected,
$notifications,
$unreadNotifications,
connectNotifications,
fetchNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
} from '../stores/notificationStore';
import { $accessToken, $user } from '../stores/authStore';
import NotificationDetailModal from './notifications/NotificationDetailModal';
function formatTimestamp(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('hr-HR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export default function NotificationBell() {
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
const [selectedNotification, setSelectedNotification] = useState(null);
const notifications = useStore($notifications);
const unread = useStore($unreadNotifications);
const isConnected = useStore($isRealtimeConnected);
const token = useStore($accessToken);
const user = useStore($user);
useEffect(() => {
setMounted(true);
}, []);
const latestNotifications = useMemo(
() => notifications.slice(0, 6),
[notifications]
);
useEffect(() => {
if (!token || !user?.id) return;
fetchNotifications();
connectNotifications();
}, [token, user?.id]);
// Stabilan placeholder za SSR + prvi client render.
if (!mounted || !token) {
return <div className="h-10 w-10" aria-hidden="true" />;
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border-hairline bg-canvas-base text-text-muted hover:bg-canvas-deep"
aria-label="Notifikacije"
>
<span className="text-lg">🔔</span>
{unread > 0 && (
<span className="absolute -right-1 -top-1 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-red-600 px-1 text-xs font-bold text-white">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div className="absolute right-0 z-30 mt-2 w-96 overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-lg">
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<div>
<p className="font-semibold text-text-main">Notifikacije</p>
<p className="text-xs text-text-muted">
Realtime: {isConnected ? 'spojen' : 'nije spojen'}
</p>
</div>
<button
type="button"
onClick={() => markAllNotificationsAsRead()}
className="text-xs font-medium text-indigo-600 hover:text-indigo-800"
>
Označi sve
</button>
</div>
<ul className="max-h-96 overflow-y-auto">
{latestNotifications.length === 0 && (
<li className="px-4 py-6 text-sm text-text-muted">Nema notifikacija.</li>
)}
{latestNotifications.map((notification) => (
<li
key={notification.id}
className={`cursor-pointer border-b border-border-hairline px-4 py-3 last:border-b-0 ${
notification.is_read ? 'bg-canvas-elevated' : 'bg-indigo-50/30'
}`}
onClick={async () => {
if (!notification.is_read) {
try {
await markNotificationAsRead(notification.id);
} catch {
// Error handling already done in store toast.
}
}
setSelectedNotification(notification);
setOpen(false);
}}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-text-main">
{notification.title}
</p>
<span className="shrink-0 text-[11px] text-text-muted">
{formatTimestamp(notification.created_at)}
</span>
</div>
<p className="mt-1 text-sm text-text-muted">{notification.message}</p>
</li>
))}
</ul>
</div>
)}
<NotificationDetailModal
open={!!selectedNotification}
notification={selectedNotification}
onClose={() => setSelectedNotification(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useStore } from '@nanostores/preact';
import { useEffect } from 'preact/hooks';
import { $toasts, clearToast } from '../stores/toastStore';
const TOAST_STYLES = {
success: 'border-emerald-200 bg-emerald-50 text-emerald-800',
error: 'border-red-200 bg-red-50 text-red-800',
warning: 'border-amber-200 bg-amber-50 text-amber-800',
info: 'border-sky-200 bg-sky-50 text-sky-800',
};
export default function ToastContainer() {
const toasts = useStore($toasts);
const toastList = Array.isArray(toasts) ? toasts : [];
useEffect(() => {
const timers = toastList.map((toast) =>
window.setTimeout(() => clearToast(toast.id), toast.timeout || 4500)
);
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [toastList]);
if (!toastList.length) {
return null;
}
return (
<div className="pointer-events-none fixed right-4 top-4 z-[60] flex w-full max-w-sm flex-col gap-2">
{toastList.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto rounded-lg border px-4 py-3 shadow-sm ${TOAST_STYLES[toast.type] || TOAST_STYLES.info}`}
role="alert"
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium">{toast.message}</p>
<button
type="button"
onClick={() => clearToast(toast.id)}
className="text-xs font-semibold opacity-75 hover:opacity-100"
aria-label="Zatvori obavijest"
>
</button>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { showToast } from '../stores/toastStore';
export default function ToastTrigger() {
return (
<button
type="button"
onClick={() => showToast('Sustav je spreman za rad.', 'info', 2500)}
className="fixed bottom-4 right-4 z-50 rounded-full border border-border-hairline bg-canvas-elevated px-4 py-2 text-xs font-semibold text-text-main shadow-sm hover:bg-canvas-deep"
>
Test Toast
</button>
);
}

View File

@@ -0,0 +1,15 @@
import { useState } from 'preact/hooks';
export default function ButtonDisplayCounter() {
const [count, setCount] = useState(0);
return (
<button
type="button"
onClick={() => setCount((value) => value + 1)}
className="rounded-e-xl border border-border-hairline border-s-0 px-3 py-2 text-xs font-mono text-text-muted hover:bg-canvas-deep"
>
Clicks: {count}
</button>
);
}

View File

@@ -0,0 +1,86 @@
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import { $accessToken, $authReady, hydrateAuthFromStorage, loginWithCredentials } from '../../stores/authStore';
import { showToast } from '../../stores/toastStore';
export default function LoginForm() {
const token = useStore($accessToken);
const authReady = useStore($authReady);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
hydrateAuthFromStorage();
}, []);
useEffect(() => {
if (authReady && token) {
window.location.href = '/';
}
}, [authReady, token]);
if (token) return null;
async function submit(event) {
event.preventDefault();
setError('');
setSubmitting(true);
try {
await loginWithCredentials(email.trim(), password);
showToast('Uspješna prijava.', 'success', 2500);
window.location.href = '/';
} catch (err) {
const message = err?.message || 'Prijava nije uspjela.';
setError(message);
showToast(message, 'error');
} finally {
setSubmitting(false);
}
}
return (
<section className="mx-auto w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated p-6 shadow-sm">
<h2 className="text-xl font-semibold text-text-main">Prijava</h2>
<p className="mt-1 text-sm text-text-muted">Prijavite se za pristup dashboardu.</p>
<form className="mt-6 space-y-4" onSubmit={submit}>
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-main">Email</span>
<input
type="email"
value={email}
onInput={(event) => setEmail(event.currentTarget.value)}
required
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-main">Lozinka</span>
<input
type="password"
value={password}
onInput={(event) => setPassword(event.currentTarget.value)}
required
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
/>
</label>
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<button
type="submit"
disabled={submitting}
className="w-full rounded-lg bg-brand-primary px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60"
>
{submitting ? 'Prijava...' : 'Prijavi se'}
</button>
</form>
</section>
);
}

View File

@@ -0,0 +1,171 @@
// src/components/crm/ClientsSection.jsx
// Prikaz liste klijenata i njihovih dizalica unutar dashboard sekcije.
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import { animated, useTransition } from '@react-spring/web';
import { $clients, $clientsLoading, fetchClients } from '../../stores/clientStore';
import { $cranes } from '../../stores/fleetDashboardStore';
import AnimatedDataTable from '../ui/AnimatedDataTable';
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
import { showToast } from '../../stores/toastStore';
export default function ClientsSection({ onSetServiceContext = null }) {
const clients = useStore($clients);
const loading = useStore($clientsLoading);
const cranes = useStore($cranes);
const [expandedId, setExpandedId] = useState(null);
const [hasMounted, setHasMounted] = useState(false);
const [selectedClientCraneId, setSelectedClientCraneId] = useState(null);
useEffect(() => {
setHasMounted(true);
}, []);
useEffect(() => {
if (!hasMounted) return;
if (clients.length === 0) fetchClients();
}, [hasMounted, clients.length]);
function cranesForClient(clientId) {
return cranes.filter((item) => String(item.client) === String(clientId));
}
const clientTransitions = useTransition(clients, {
keys: (client) => client.id,
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
trail: 30,
config: { tension: 230, friction: 26 },
});
function handleSetServiceContext() {
if (!selectedClientCraneId) {
showToast('Prvo označite redak dizalice.', 'warning');
return;
}
onSetServiceContext?.(selectedClientCraneId);
}
return (
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h3 className="font-semibold text-text-main">Klijenti</h3>
<div className="flex items-center gap-3">
<ServiceRecordCreateButton
onClick={handleSetServiceContext}
label="Postavi servisni kontekst"
/>
<button
type="button"
onClick={() => fetchClients()}
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
>
Osvježi
</button>
</div>
</div>
{!hasMounted && (
<p className="px-4 py-6 text-center text-sm text-text-muted">Učitavanje</p>
)}
{hasMounted && loading && (
<p className="px-4 py-6 text-center text-sm text-text-muted">Učitavanje</p>
)}
{hasMounted && !loading && clients.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Nema klijenata. Dodajte klijente u Django admin ili koristite CRM API.
</p>
)}
{hasMounted && !loading && clients.length > 0 && (
<div className="divide-y divide-border-hairline">
{clientTransitions((style, client) => {
const cvs = cranesForClient(client.id);
const isOpen = expandedId === client.id;
const craneColumns = [
{ key: 'registration', label: 'Oznaka', className: 'py-2 pr-4 text-left' },
{ key: 'model', label: 'Make / Model', className: 'py-2 pr-4 text-left' },
{ key: 'km', label: 'Km', className: 'py-2 pr-4 text-left' },
{ key: 'nextService', label: 'Sljedeći servis', className: 'py-2 pr-4 text-left' },
{ key: 'type', label: 'Vrsta', className: 'py-2 text-left' },
];
return (
<animated.div key={client.id} style={style}>
<button
type="button"
onClick={() => {
setExpandedId(isOpen ? null : client.id);
setSelectedClientCraneId(null);
}}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
>
<div>
<span className="font-medium text-text-main">{client.name}</span>
<span className="ml-2 text-xs text-text-muted">{client.tax_id}</span>
<span className="ml-3 text-xs text-text-muted">{client.city}</span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
{cvs.length} dizalica
</span>
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
</div>
</button>
{isOpen && (
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
{cvs.length === 0 ? (
<p className="text-sm text-text-muted">
Nema dizalica dodijeljenih ovom klijentu.
Dodajte dizalicu u Django admin i povežite je s klijentom.
</p>
) : (
<AnimatedDataTable
columns={craneColumns}
rows={cvs}
rowKey={(item) => item.id}
headClassName="text-xs uppercase tracking-wide text-text-muted"
bodyClassName="divide-y divide-border-hairline"
rowClassName="hover:bg-canvas-deep"
tableClassName="min-w-full text-sm"
wrapperClassName="overflow-x-auto"
renderRow={(item) => {
const isSelected = String(item.id) === String(selectedClientCraneId);
const cellClassName = isSelected ? 'bg-emerald-50' : '';
const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : '';
return (
<>
<td className={`py-2 pr-4 font-medium cursor-pointer ${firstCellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
{item.service_interval_km
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
: '—'}
</td>
<td className={`py-2 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
<span className={item.is_company_vehicle
? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700'
: 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700'
}>
{item.is_company_vehicle ? 'Fleet' : 'Kupac'}
</span>
</td>
</>
);}}
/>
)}
</div>
)}
</animated.div>
);
})}
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,83 @@
import { showToast } from '../../stores/toastStore';
import ServiceContextSelector from './ServiceContextSelector';
const NAV_ITEMS = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'work-orders', label: 'Putni nalozi' },
{ key: 'service-records', label: 'Servisni zapisi' },
{ key: 'cranes', label: 'Dizalice' },
{ key: 'clients', label: 'Klijenti' },
];
export default function DashboardTopbar({
activeSection,
onSectionChange,
onOpenWorkOrderModal,
onOpenTaskModal,
pendingSyncCount,
onNewServiceRecord,
showSectionNav = true,
}) {
const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
return (
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
{/* ── Gornji red ───────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div>
<p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p>
<h2 className="text-xl font-semibold text-text-main">{sectionTitle}</h2>
</div>
<div className="flex items-center gap-3">
{pendingSyncCount > 0 && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
Offline queue: {pendingSyncCount}
</span>
)}
<button
type="button"
onClick={onOpenWorkOrderModal}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
+ Novi putni nalog
</button>
<button
type="button"
onClick={onOpenTaskModal}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
+ Novi radni zadatak
</button>
</div>
</div>
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
<ServiceContextSelector onNewServiceRecord={onNewServiceRecord} />
{/* ── Navigacijski tabovi ──────────────────────────────── */}
{showSectionNav && (
<nav className="overflow-x-auto border-t border-border-hairline px-4 sm:px-6">
<ul className="flex min-w-max items-center gap-1 py-2 text-sm">
{NAV_ITEMS.map((item) => (
<li key={item.key}>
<button
type="button"
onClick={() => {
onSectionChange(item.key);
}}
className={
activeSection === item.key
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
}
>
{item.label}
</button>
</li>
))}
</ul>
</nav>
)}
</header>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
// src/components/dashboard/ServiceContextSelector.jsx
//
// Persistentni selektor "Servisni kontekst" — odabir kupca + dizalice.
// Stanje se čuva u localStorage i u serviceContextStore (reaktivno).
//
// USAGE:
// <ServiceContextSelector onNewServiceRecord={({ craneId, clientId }) => ...} />
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import { $clients, fetchClients } from '../../stores/clientStore';
import { $cranes } from '../../stores/fleetDashboardStore';
import { $accessToken } from '../../stores/authStore';
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
import {
$selectedClientId, $selectedVehicleId,
hydrateServiceContext, setSelectedClient, setSelectedVehicle, clearServiceContext,
} from '../../stores/serviceContextStore';
export default function ServiceContextSelector({ onNewServiceRecord }) {
const clients = useStore($clients);
const cranes = useStore($cranes);
const accessToken = useStore($accessToken);
const selectedClientId = useStore($selectedClientId);
const selectedVehicleId = useStore($selectedVehicleId);
const [editMode, setEditMode] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
hydrateServiceContext();
}, []);
useEffect(() => {
if (!accessToken) return;
if (clients.length > 0) return;
fetchClients();
}, [accessToken, clients.length]);
const fallbackClients = Array.from(
new Map(
cranes
.filter((item) => item?.client && item?.client_name)
.map((item) => [String(item.client), { id: item.client, name: item.client_name }])
).values()
);
const availableClients = clients.length > 0 ? clients : fallbackClients;
const clientCranes = cranes.filter((item) => String(item.client) === String(selectedClientId));
const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
const showSelector = editMode || !selectedClientId;
function handleClientChange(e) {
setSelectedClient(e.target.value);
}
function handleVehicleChange(e) {
setSelectedVehicle(e.target.value);
}
function handleClearSelection() {
clearServiceContext();
setEditMode(false);
}
if (!hasMounted) return null;
return (
<div className="border-t border-border-hairline bg-canvas-base/60 px-4 py-2 sm:px-6">
{showSelector ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-muted">Servisni kontekst:</span>
<select
value={selectedClientId ?? ''}
onChange={handleClientChange}
className="rounded-md border border-border-hairline bg-canvas-base px-2 py-1 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<option value=""> Odaberi kupca </option>
{availableClients.length === 0 && (
<option value="" disabled>Nema dostupnih kupaca</option>
)}
{availableClients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
{selectedClientId && (
<select
value={selectedVehicleId ?? ''}
onChange={handleVehicleChange}
className="rounded-md border border-border-hairline bg-canvas-base px-2 py-1 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<option value=""> Odaberi dizalicu </option>
{clientCranes.length > 0
? clientCranes.map((item) => (
<option key={item.id} value={item.id}>
{item.registration_number} {item.make} {item.model}
</option>
))
: <option disabled>Nema dizalica za ovog kupca</option>
}
</select>
)}
{selectedClientId && selectedVehicleId && (
<button
type="button"
onClick={() => setEditMode(false)}
className="rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700"
>
Potvrdi
</button>
)}
{editMode && (
<button
type="button"
onClick={() => setEditMode(false)}
className="rounded-md border border-border-hairline px-3 py-1 text-xs text-text-muted hover:bg-canvas-deep"
>
Odustani
</button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs text-text-muted">Servisni kontekst:</span>
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
{selectedClient?.name ?? '…'}
</span>
{selectedCrane && (
<>
<span className="text-xs text-text-muted">/</span>
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
{selectedCrane.registration_number} {selectedCrane.make} {selectedCrane.model}
</span>
</>
)}
{selectedVehicleId && onNewServiceRecord && (
<ServiceRecordCreateButton
onClick={() => {
onNewServiceRecord({ craneId: selectedVehicleId, clientId: selectedClientId });
}}
/>
)}
<button
type="button"
onClick={() => setEditMode(true)}
className="rounded-md border border-border-hairline px-2 py-1 text-xs text-text-muted hover:bg-canvas-deep"
>
Promijeni
</button>
<button
type="button"
onClick={handleClearSelection}
className="rounded-md border border-border-hairline px-2 py-1 text-xs text-text-muted hover:bg-red-50 hover:text-red-600"
>
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useStore } from '@nanostores/preact';
import { $syncLog } from '../../stores/fleetDashboardStore';
import AnimatedDataTable from '../ui/AnimatedDataTable';
function rowStyle(status) {
if (status === 'success') return 'text-emerald-700';
if (status === 'failed') return 'text-red-700';
return 'text-amber-700';
}
function formatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('hr-HR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
}
export default function SyncLogPanel() {
const rows = useStore($syncLog);
const syncColumns = [
{ key: 'time', label: 'Vrijeme', className: 'px-4 py-2' },
{ key: 'status', label: 'Status', className: 'px-4 py-2' },
{ key: 'type', label: 'Tip', className: 'px-4 py-2' },
{ key: 'detail', label: 'Detalj', className: 'px-4 py-2' },
];
return (
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
<div className="border-b border-border-hairline px-4 py-3">
<h3 className="font-semibold text-text-main">Status sinkronizacije</h3>
<p className="text-xs text-text-muted">Što je poslano, što je ostalo u redu čekanja i što nije uspjelo.</p>
</div>
<AnimatedDataTable
columns={syncColumns}
rows={rows}
rowKey={(item) => item.id}
emptyMessage="Još nema zapisa sinkronizacije."
wrapperClassName="max-h-72 overflow-auto"
renderRow={(item) => (
<>
<td className="px-4 py-2 text-text-muted">{formatDate(item.created_at)}</td>
<td className={`px-4 py-2 font-semibold ${rowStyle(item.status)}`}>{item.status}</td>
<td className="px-4 py-2 text-text-main">{item.entity}</td>
<td className="px-4 py-2 text-text-muted">{item.detail || '-'}</td>
</>
)}
/>
</article>
);
}

View File

@@ -0,0 +1,188 @@
import { useEffect, useState } from 'preact/hooks';
import { createTask, getStatusLabel } from '../../stores/taskStore';
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
const INITIAL_FORM = {
title: '',
description: '',
status: 'aktivan',
work_order: '',
};
export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
const [form, setForm] = useState(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!open) {
setForm(INITIAL_FORM);
setSubmitting(false);
setError('');
}
}, [open]);
if (!open) return null;
function setField(name, value) {
setForm((prev) => ({ ...prev, [name]: value }));
}
async function submit(event) {
event.preventDefault();
setError('');
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if (form.status === 'neaktivan' && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
if (!contextCrane?.id) {
setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
return;
}
setSubmitting(true);
try {
await createTask({
title,
description: String(form.description || '').trim(),
status: form.status || 'aktivan',
vehicle: contextCrane.id,
work_order: form.work_order || null,
});
await onSuccess?.();
onClose?.();
} catch (err) {
setError(err?.message || 'Neuspješno kreiranje zadatka.');
} finally {
setSubmitting(false);
}
}
return (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
onClick={onClose}
>
<div className="flex min-h-full items-start justify-center">
<div
className="w-full max-w-xl max-h-[calc(100vh-2rem)] overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Novi radni zadatak</h3>
<p className="text-xs text-text-muted">
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naslov zadatka *</span>
<input
type="text"
value={form.title}
onInput={(event) => setField('title', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis</span>
<textarea
rows={3}
value={form.description}
onInput={(event) => setField('description', event.currentTarget.value)}
disabled={submitting}
className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status</span>
<select
value={form.status}
onChange={(event) => setField('status', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{getStatusLabel(status)}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Povezani putni nalog</span>
<select
value={form.work_order}
onChange={(event) => setField('work_order', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Bez povezanog naloga </option>
{workOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {formatPurposeLabel(workOrder.purpose) || '-'}
</option>
))}
</select>
{workOrders.length === 0 && (
<span className="text-[11px] text-amber-600">
Nema dostupnih naloga za odabranu dizalicu.
</span>
)}
</label>
</div>
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
disabled={submitting}
>
Odustani
</button>
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
disabled={submitting}
>
{submitting ? 'Spremanje...' : 'Kreiraj radni zadatak'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { formatEntityCode } from '../../lib/displayIds';
function formatDate(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString('hr-HR');
}
function formatCost(value) {
const numeric = Number(value);
if (Number.isNaN(numeric)) return '-';
return `${numeric.toFixed(2)} EUR`;
}
export default function TaskServiceRecordsModal({
open,
task,
records = [],
workOrders = [],
onClose,
onOpenServiceRecord,
onAssignWorkOrder,
onCreateServiceRecord,
}) {
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
const [assignError, setAssignError] = useState('');
useEffect(() => {
if (!open || typeof window === 'undefined') return;
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [open]);
useEffect(() => {
if (!task) return;
setSelectedWorkOrderId(task.work_order ? String(task.work_order) : '');
setShowWorkOrderEditor(!task.work_order);
setAssignError('');
setAssigningWorkOrder(false);
}, [task?.id, task?.work_order]);
const currentWorkOrder = useMemo(
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
[workOrders, task?.work_order, selectedWorkOrderId]
);
const sortedWorkOrders = useMemo(
() => [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))),
[workOrders]
);
const handleWorkOrderChange = async (event) => {
const nextValue = String(event.currentTarget.value || '');
setSelectedWorkOrderId(nextValue);
setAssignError('');
setAssigningWorkOrder(true);
try {
await onAssignWorkOrder?.(task, nextValue || null);
setShowWorkOrderEditor(false);
} catch (error) {
setAssignError(error?.message || 'Neuspješno spremanje putnog naloga za radni zadatak.');
} finally {
setAssigningWorkOrder(false);
}
};
if (!open || !task) return null;
return (
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-4xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl max-h-[calc(100vh-6rem)] overflow-hidden"
>
<div className="w-full flex flex-col h-full">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Radni zadatak detalji</h3>
<p className="text-xs text-text-muted">
Radni zadatak: {task.title || '-'} Status: {task.status || '-'} Zapisa: {records.length}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4">
<div className="space-y-4">
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni nalozi</h4>
{currentWorkOrder && !showWorkOrderEditor ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded border border-border-hairline bg-canvas-elevated px-3 py-2">
<p className="text-sm text-text-main">
{formatEntityCode('PN', currentWorkOrder.id)} {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} {currentWorkOrder.status || '-'}
</p>
<button
type="button"
onClick={() => setShowWorkOrderEditor(true)}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Izmijeni putni nalog
</button>
</div>
) : (
<div className="space-y-2">
<select
value={selectedWorkOrderId}
onChange={handleWorkOrderChange}
disabled={assigningWorkOrder}
className="w-full rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main disabled:opacity-60"
>
<option value=""> Odaberi putni nalog </option>
{sortedWorkOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {workOrder.status || '-'}
</option>
))}
</select>
<p className="text-xs text-text-muted">
{assigningWorkOrder ? 'Spremanje odabira putnog naloga...' : 'Odabir se automatski sprema u bazu.'}
</p>
</div>
)}
{assignError && (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{assignError}
</div>
)}
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-text-main">Povezani servisni zapisi</h4>
<button
type="button"
onClick={() => onCreateServiceRecord?.(task)}
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700"
>
+ Servisni zapis
</button>
</div>
{records.length === 0 ? (
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-4 py-3 text-sm text-text-muted">
Nema povezanih servisnih zapisa za odabrani radni zadatak.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border-hairline">
<table className="w-full table-auto text-sm">
<thead className="bg-canvas-base text-left text-text-muted">
<tr>
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Opis</th>
<th className="px-3 py-2">Trošak</th>
<th className="px-3 py-2">Datoteke</th>
<th className="px-3 py-2">Akcija</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.id} className="border-t border-border-hairline">
<td className="px-3 py-2">{formatDate(record.service_date)}</td>
<td className="max-w-[420px] truncate px-3 py-2" title={record.description || ''}>
{record.description || '-'}
</td>
<td className="px-3 py-2">{formatCost(record.cost)}</td>
<td className="px-3 py-2">{record.files_count ?? (record.photos_count ?? 0)}</td>
<td className="px-3 py-2">
<button
type="button"
onClick={() => onOpenServiceRecord?.(record)}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
</ModalShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { animated, useTransition } from '@react-spring/web';
function getBrowserApiBase() {
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
if (typeof window === 'undefined') {
return apiUrl || 'http://localhost:8001/api/';
}
try {
if (apiUrl) {
const parsed = new URL(apiUrl);
const internalHosts = new Set(['backend', 'localhost', '127.0.0.1']);
const isInternalDockerHost = parsed.hostname === 'backend';
if (!isInternalDockerHost) {
return parsed.toString();
}
}
} catch {
// fallback is handled below
}
return `${window.location.protocol}//${window.location.hostname}:8001/api/`;
}
export function resolveMediaUrl(pathOrUrl) {
if (!pathOrUrl || typeof window === 'undefined') return '';
let finalSrc = String(pathOrUrl);
const apiUrl = getBrowserApiBase();
const baseUrl = apiUrl.replace(/api\/?$/, '');
if (/^https?:\/\//i.test(finalSrc)) {
if (finalSrc.includes('backend:8000')) {
return finalSrc.replace('http://backend:8000/', baseUrl);
}
return finalSrc;
}
if (finalSrc.startsWith('/')) {
return `${baseUrl.replace(/\/$/, '')}${finalSrc}`;
}
return new URL(finalSrc, apiUrl).toString();
}
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
const normalizedImages = useMemo(() => (
Array.isArray(images)
? images.filter(Boolean)
: (typeof images === 'string' && images ? [images] : [])
), [images]);
const [currentImgIdx, setCurrentImgIdx] = useState(0);
useEffect(() => {
setCurrentImgIdx(0);
}, [normalizedImages.length]);
useEffect(() => {
if (editMode || normalizedImages.length <= 1) return undefined;
const interval = window.setInterval(() => {
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
}, 3000);
return () => window.clearInterval(interval);
}, [normalizedImages.length, editMode]);
if (normalizedImages.length === 0) {
return (
<div className="flex h-64 w-full flex-col items-center justify-center border-b border-border-hairline bg-canvas-deep font-mono text-[11px] uppercase tracking-wider text-text-muted/40">
<svg className="mb-2 h-8 w-8 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
{emptyLabel}
</div>
);
}
const prevSlide = (event) => {
event.preventDefault();
setCurrentImgIdx((prev) => (prev - 1 + normalizedImages.length) % normalizedImages.length);
};
const nextSlide = (event) => {
event.preventDefault();
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
};
const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || '');
const imageTransitions = useTransition(currentImgIdx, {
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
leave: { opacity: 0, transform: 'translate3d(-8%,0,0) scale(1.01)' },
config: { tension: 220, friction: 26 },
});
return (
<div className="group relative h-72 w-full overflow-hidden border-b border-border-hairline bg-canvas-base sm:h-80">
<div className="relative h-full w-full overflow-hidden">
{imageTransitions((style, idx) => (
<animated.img
key={idx}
src={resolveMediaUrl(normalizedImages[idx] || finalSrc)}
alt={`Dokumentacija s terena ${idx + 1}`}
style={style}
className="absolute inset-0 h-full w-full object-cover will-change-transform"
/>
))}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 to-transparent" />
{normalizedImages.length > 1 && (
<>
<button
type="button"
onClick={prevSlide}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full border border-border-hairline bg-canvas-elevated/80 p-2 text-text-main opacity-0 shadow-lg transition-opacity duration-200 hover:bg-canvas-base group-hover:opacity-100"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
onClick={nextSlide}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full border border-border-hairline bg-canvas-elevated/80 p-2 text-text-main opacity-0 shadow-lg transition-opacity duration-200 hover:bg-canvas-base group-hover:opacity-100"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="absolute inset-x-0 bottom-3 z-10 flex justify-center gap-1.5">
{normalizedImages.map((_, idx) => (
<button
key={idx}
type="button"
onClick={(event) => {
event.preventDefault();
setCurrentImgIdx(idx);
}}
className={`h-1.5 rounded-full transition-all duration-300 ${idx === currentImgIdx ? 'w-4 bg-brand-accent' : 'w-1.5 bg-white/40 hover:bg-white/70'}`}
/>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,258 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import {
downloadWorkOrderPdf,
downloadWorkOrderInvoicesPdf,
downloadWorkOrderServiceRecordsPdf,
fetchWorkOrderById,
fetchWorkOrderInvoices,
fetchWorkOrderTaskServiceContext,
} from '../../stores/fleetDashboardStore';
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatEntityCode } from '../../lib/displayIds';
import { resolveMediaUrl } from './WorkOrderImageCarousel';
function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') {
return '';
}
const params = new URLSearchParams(window.location.search);
return params.get('workOrderId') || '';
}
function InvoiceImagePreview({ imageUrl, alt }) {
const [broken, setBroken] = useState(false);
if (!imageUrl || broken) {
return (
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
Bez slike
</div>
);
}
return (
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="inline-flex">
<img
src={imageUrl}
alt={alt}
onError={() => setBroken(true)}
className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
/>
</a>
);
}
export default function WorkOrderInvoicesPdfPage() {
const authReady = useStore($authReady);
const token = useStore($accessToken);
const [workOrderId, setWorkOrderId] = useState('');
const [workOrder, setWorkOrder] = useState(null);
const [invoices, setInvoices] = useState([]);
const [taskContext, setTaskContext] = useState({ tasks: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
hydrateAuthFromStorage();
const id = readWorkOrderIdFromQuery();
setWorkOrderId(id);
}, []);
useEffect(() => {
if (authReady) {
fetchNotifications();
connectNotifications();
}
}, [authReady]);
useEffect(() => {
if (!workOrderId) {
setLoading(false);
setError('Nedostaje workOrderId u URL-u.');
return;
}
if (!authReady) {
return;
}
if (!token) {
setLoading(false);
setError('Sesija nije aktivna. Prijavite se ponovno.');
return;
}
let cancelled = false;
setLoading(true);
setError('');
(async () => {
try {
const [contextPayload, order, invoiceItems] = await Promise.all([
fetchWorkOrderTaskServiceContext(workOrderId),
fetchWorkOrderById(workOrderId),
fetchWorkOrderInvoices(workOrderId),
]);
if (cancelled) {
return;
}
setTaskContext(contextPayload);
setWorkOrder(order);
setInvoices(invoiceItems);
} catch (err) {
if (!cancelled) {
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [workOrderId, authReady, token]);
const title = useMemo(() => {
if (!workOrder) {
return 'Računi putnog naloga';
}
return `${formatEntityCode('PN', workOrder.id)}`;
}, [workOrder]);
return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-text-main">{title}</h1>
<p className="text-sm text-text-muted">
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
</p>
</div>
<button
type="button"
onClick={() => downloadWorkOrderPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF putnog naloga
</button>
</div>
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Task i povezani servisni zapisi</h2>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF servisnih zapisa
</button>
</div>
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
{!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
<p className="text-sm text-text-muted">Nema taskova povezanih s ovim putnim nalogom.</p>
)}
{!loading && !error && Array.isArray(taskContext.tasks) && taskContext.tasks.length > 0 && (
<div className="space-y-4">
{taskContext.tasks.map((task) => (
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
</div>
<div className="mt-3 space-y-3">
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
) : (
task.service_records.map((record) => (
<div key={record.id} className="rounded border border-border-hairline px-3 py-2">
<div className="space-y-1 text-xs text-text-main">
<div><span className="font-semibold">Servis:</span> {record.description || '-'}</div>
<div><span className="font-semibold">Datum:</span> {record.service_date || '-'}</div>
<div><span className="font-semibold">Trošak:</span> {record.cost || '-'} EUR</div>
<div><span className="font-semibold">KM:</span> {record.mileage ?? '-'}</div>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{Array.isArray(record.photos) && record.photos.length > 0 ? (
record.photos.map((photo) => (
<a
key={photo.id}
href={resolveMediaUrl(photo.optimized_url || photo.image_url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
>
<img
src={resolveMediaUrl(photo.optimized_url || photo.image_url)}
alt={`Servisna slika ${photo.id}`}
className="h-20 w-20 rounded border border-border-hairline object-cover"
/>
</a>
))
) : (
<span className="text-xs text-text-muted">Bez slika</span>
)}
</div>
</div>
))
)}
</div>
</article>
))}
</div>
)}
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
<button
type="button"
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi račune putnog naloga
</button>
</div>
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
{!loading && error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{!loading && !error && invoices.length === 0 && (
<p className="text-sm text-text-muted">Za ovaj putni nalog nema unesenih računa.</p>
)}
{!loading && !error && invoices.length > 0 && (
<div className="space-y-3">
{invoices.map((invoice) => (
<article key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
</div>
<InvoiceImagePreview
imageUrl={resolveMediaUrl(invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`}
/>
</div>
</article>
))}
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,592 @@
import { useEffect, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $user } from '../../stores/authStore';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
{ value: 'kontrola', label: 'Kontrola' },
{ value: 'redovni_pregled', label: 'Redovni pregled' },
];
const INITIAL_FORM = {
has_travel_order: false,
start_mileage: '',
end_mileage: '',
servicer_vehicle_registration: '',
servicer_vehicle_make_model: '',
servicer_vehicle_start_mileage: '',
servicer_vehicle_end_mileage: '',
servicer_vehicle_fuel_cost: '',
location: '',
travel_start_at: '',
travel_end_at: '',
purpose: '',
notes: '',
status: 'open',
};
export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit }) {
const user = useStore($user);
const [form, setForm] = useState(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState([]);
const [selectedLocation, setSelectedLocation] = useState(null);
const [searchingLocation, setSearchingLocation] = useState(false);
const [mapError, setMapError] = useState('');
useEffect(() => {
if (!open) {
setForm(INITIAL_FORM);
setSubmitting(false);
setErrorMessage('');
setMapPickerOpen(false);
setLocationQuery('');
setLocationResults([]);
setSelectedLocation(null);
setSearchingLocation(false);
setMapError('');
return;
}
setForm((prev) => ({
...prev,
start_mileage: selectedCrane ? String(selectedCrane.current_mileage ?? '') : prev.start_mileage,
servicer_vehicle_registration: user?.assigned_vehicle?.registration_number || '',
servicer_vehicle_make_model: `${user?.assigned_vehicle?.make || ''} ${user?.assigned_vehicle?.model || ''}`.trim(),
servicer_vehicle_start_mileage: user?.assigned_vehicle?.current_mileage == null ? '' : String(user.assigned_vehicle.current_mileage),
servicer_vehicle_end_mileage: '',
servicer_vehicle_fuel_cost: '0.00',
}));
}, [open, selectedCrane, user?.assigned_vehicle?.id]);
if (!open) return null;
const selectedMapEmbedUrl = (() => {
if (!selectedLocation) return null;
const lat = Number(selectedLocation.lat);
const lon = Number(selectedLocation.lon);
if (Number.isNaN(lat) || Number.isNaN(lon)) return null;
const delta = 0.01;
const bbox = `${lon - delta}%2C${lat - delta}%2C${lon + delta}%2C${lat + delta}`;
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat}%2C${lon}`;
})();
const searchLocation = async () => {
const query = locationQuery.trim();
if (!query) {
setMapError('Unesite naziv lokacije za pretragu.');
return;
}
setMapError('');
setSearchingLocation(true);
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=8&q=${encodeURIComponent(query)}`
);
if (!response.ok) {
throw new Error('Pretraga lokacije nije uspjela.');
}
const payload = await response.json();
const results = Array.isArray(payload) ? payload : [];
setLocationResults(results);
if (results.length === 0) {
setMapError('Lokacija nije pronađena. Pokušajte s detaljnijim nazivom.');
}
} catch (error) {
setMapError(error?.message || 'Greška pri dohvaćanju karte.');
} finally {
setSearchingLocation(false);
}
};
const submit = async (event) => {
event.preventDefault();
setErrorMessage('');
if (!selectedCrane) {
setErrorMessage('Odaberite dizalicu u Servisnom kontekstu prije kreiranja naloga.');
return;
}
if (!form.purpose.trim()) {
setErrorMessage('Svrha naloga je obavezna.');
return;
}
let startMileage = null;
let endMileage = null;
let servicerStartMileage = null;
let servicerEndMileage = null;
let servicerFuelCost = null;
let travelStart = null;
let travelEnd = null;
if (form.has_travel_order) {
if (!form.start_mileage || !form.location.trim()) {
setErrorMessage('Za putni nalog unesite početnu kilometražu i lokaciju.');
return;
}
startMileage = Number(form.start_mileage);
endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
if (Number.isNaN(startMileage) || startMileage < 0) {
setErrorMessage('Početna kilometraža mora biti valjan nenegativan broj.');
return;
}
if (endMileage != null && (Number.isNaN(endMileage) || endMileage < startMileage)) {
setErrorMessage('Završna kilometraža mora biti veća ili jednaka početnoj.');
return;
}
if (!form.travel_start_at || !form.travel_end_at) {
setErrorMessage('Unesite vrijeme početka i kraja puta.');
return;
}
travelStart = new Date(form.travel_start_at);
travelEnd = new Date(form.travel_end_at);
if (Number.isNaN(travelStart.getTime()) || Number.isNaN(travelEnd.getTime())) {
setErrorMessage('Vrijeme puta nije u ispravnom formatu.');
return;
}
if (travelEnd < travelStart) {
setErrorMessage('Vrijeme kraja puta ne može biti prije početka puta.');
return;
}
}
if (form.servicer_vehicle_start_mileage !== '') {
servicerStartMileage = Number(form.servicer_vehicle_start_mileage);
if (Number.isNaN(servicerStartMileage) || servicerStartMileage < 0) {
setErrorMessage('Početna kilometraža vozila servisera mora biti valjan nenegativan broj.');
return;
}
}
if (form.servicer_vehicle_end_mileage !== '') {
servicerEndMileage = Number(form.servicer_vehicle_end_mileage);
if (Number.isNaN(servicerEndMileage) || servicerEndMileage < 0) {
setErrorMessage('Završna kilometraža vozila servisera mora biti valjan nenegativan broj.');
return;
}
if (servicerStartMileage == null) {
setErrorMessage('Unesite početnu kilometražu vozila servisera ako unosite završnu.');
return;
}
if (servicerEndMileage <= servicerStartMileage) {
setErrorMessage('Završna kilometraža vozila servisera mora biti veća od početne.');
return;
}
}
if (form.servicer_vehicle_fuel_cost !== '') {
servicerFuelCost = Number(form.servicer_vehicle_fuel_cost);
if (Number.isNaN(servicerFuelCost) || servicerFuelCost < 0) {
setErrorMessage('Trošak goriva vozila servisera mora biti valjan nenegativan broj.');
return;
}
servicerFuelCost = Number(servicerFuelCost.toFixed(2));
}
setSubmitting(true);
try {
await onSubmit({
vehicle: selectedCrane.id,
start_mileage: startMileage,
end_mileage: endMileage,
servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
servicer_vehicle_make_model: form.servicer_vehicle_make_model.trim(),
servicer_vehicle_start_mileage: servicerStartMileage,
servicer_vehicle_end_mileage: servicerEndMileage,
servicer_vehicle_fuel_cost: servicerFuelCost,
location: form.has_travel_order ? form.location.trim() : '',
travel_start_at: travelStart ? travelStart.toISOString() : null,
travel_end_at: travelEnd ? travelEnd.toISOString() : null,
purpose: form.purpose.trim(),
notes: form.notes.trim(),
status: form.status,
});
onClose();
} finally {
setSubmitting(false);
}
};
return (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
>
<div className="w-full flex flex-col">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<h3 className="text-lg font-semibold text-text-main">Novi radni/putni nalog</h3>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select
value={form.status}
onChange={(event) => {
const nextStatus = event.target.value;
setForm({
...form,
status: nextStatus,
end_mileage: nextStatus === 'open' ? '' : form.end_mileage,
});
}}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
</label>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-3 text-sm font-semibold text-text-main">Podaci dizalice</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Dizalica</span>
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main">
{selectedCrane
? (selectedCrane.registration_number || `Dizalica #${selectedCrane.id}`)
: 'Nema odabrane dizalice u kontekstu'}
</div>
</div>
<div className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trenutna km dizalice</span>
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main">
{selectedCrane?.current_mileage ?? 0}
</div>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna kilometraža dizalice {form.has_travel_order ? '*' : ''}</span>
<input
type="number"
min="0"
value={form.start_mileage}
onInput={(event) => setForm({ ...form, start_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!form.has_travel_order}
required={form.has_travel_order}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna kilometraža dizalice</span>
<input
type="number"
min="0"
value={form.end_mileage}
onInput={(event) => setForm({ ...form, end_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!form.has_travel_order || form.status === 'open'}
/>
</label>
</div>
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-3 text-sm font-semibold text-text-main">Vozilo servisera (obračun troškova)</h4>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Registracija vozila servisera</span>
<input
type="text"
value={form.servicer_vehicle_registration}
onInput={(event) => setForm({ ...form, servicer_vehicle_registration: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
placeholder="Npr. ZG-1234-AA"
disabled
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Marka / model vozila servisera</span>
<input
type="text"
value={form.servicer_vehicle_make_model}
onInput={(event) => setForm({ ...form, servicer_vehicle_make_model: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
placeholder="Npr. VW Caddy"
disabled
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna kilometraža vozila servisera</span>
<input
type="number"
min="0"
value={form.servicer_vehicle_start_mileage}
onInput={(event) => setForm({ ...form, servicer_vehicle_start_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna kilometraža vozila servisera</span>
<input
type="number"
min="0"
value={form.servicer_vehicle_end_mileage}
onInput={(event) => setForm({ ...form, servicer_vehicle_end_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="font-medium text-text-main">Trošak goriva vozila servisera (EUR)</span>
<input
type="number"
min="0"
step="0.01"
value={form.servicer_vehicle_fuel_cost}
onInput={(event) => setForm({ ...form, servicer_vehicle_fuel_cost: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled
/>
</label>
</div>
{!user?.assigned_vehicle && (
<p className="mt-2 text-xs text-amber-700">
Korisnik nema dodijeljeno vozilo servisera, pa polja nisu popunjena.
</p>
)}
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
<div className="flex gap-2">
<input
type="text"
value={form.location}
onInput={(event) => setForm({ ...form, location: event.currentTarget.value })}
className="flex-1 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
placeholder="Npr. Klijent Zagreb, servisna lokacija"
disabled={!form.has_travel_order}
required={form.has_travel_order}
/>
<button
type="button"
onClick={() => {
setLocationQuery(form.location || '');
setLocationResults([]);
setSelectedLocation(null);
setMapError('');
setMapPickerOpen(true);
}}
disabled={!form.has_travel_order || submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Prikaži kartu
</button>
</div>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početak puta {form.has_travel_order ? '*' : ''}</span>
<input
type="datetime-local"
value={form.travel_start_at}
onInput={(event) => setForm({ ...form, travel_start_at: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!form.has_travel_order}
required={form.has_travel_order}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Kraj puta {form.has_travel_order ? '*' : ''}</span>
<input
type="datetime-local"
value={form.travel_end_at}
onInput={(event) => setForm({ ...form, travel_end_at: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!form.has_travel_order}
required={form.has_travel_order}
/>
</label>
</div>
<label className="flex items-center gap-2 text-sm text-text-main">
<input
type="checkbox"
checked={form.has_travel_order}
onChange={(event) => {
const checked = event.currentTarget.checked;
setForm((prev) => ({
...prev,
has_travel_order: checked,
start_mileage: checked ? (prev.start_mileage || String(selectedCrane?.current_mileage ?? '')) : '',
end_mileage: checked ? prev.end_mileage : '',
location: checked ? prev.location : '',
travel_start_at: checked ? prev.travel_start_at : '',
travel_end_at: checked ? prev.travel_end_at : '',
}));
}}
/>
Putni nalog uključuje putovanje (lokacija i vrijeme)
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Svrha naloga *</span>
<select
value={form.purpose}
onChange={(event) => setForm({ ...form, purpose: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
required
>
<option value=""> Odaberi vrstu </option>
{PURPOSE_OPTIONS.map((purpose) => (
<option key={purpose.value} value={purpose.value}>{purpose.label}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Napomena</span>
<textarea
value={form.notes}
onInput={(event) => setForm({ ...form, notes: event.currentTarget.value })}
className="min-h-24 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
/>
</label>
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
Odustani
</button>
<button
type="submit"
disabled={submitting || !selectedCrane}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremam...' : 'Spremi nalog'}
</button>
</div>
</form>
</div>
</ModalShell>
{mapPickerOpen && (
<ModalShell
onClose={() => setMapPickerOpen(false)}
overlayClassName="z-[60] overflow-y-auto p-4 pt-2"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-3xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
>
<div className="w-full">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<h4 className="text-base font-semibold text-text-main">Odabir lokacije na karti</h4>
<button
type="button"
onClick={() => setMapPickerOpen(false)}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori kartu"
>
</button>
</div>
<div className="space-y-3 px-5 py-4">
<div className="flex gap-2">
<input
type="text"
value={locationQuery}
onInput={(event) => setLocationQuery(event.currentTarget.value)}
className="flex-1 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder="Upišite adresu ili naziv lokacije"
/>
<button
type="button"
onClick={searchLocation}
disabled={searchingLocation}
className="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{searchingLocation ? 'Tražim...' : 'Pretraži'}
</button>
</div>
{mapError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{mapError}
</div>
)}
{locationResults.length > 0 && (
<div className="max-h-44 overflow-y-auto rounded-lg border border-border-hairline bg-canvas-base">
{locationResults.map((result) => (
<button
key={result.place_id}
type="button"
onClick={() => setSelectedLocation(result)}
className="block w-full border-b border-border-hairline px-3 py-2 text-left text-sm text-text-main hover:bg-canvas-deep last:border-b-0"
>
{result.display_name}
</button>
))}
</div>
)}
{selectedLocation && selectedMapEmbedUrl && (
<div className="space-y-2">
<p className="text-sm text-text-main">
Odabrano: <span className="font-medium">{selectedLocation.display_name}</span>
</p>
<iframe
title="Karta odabrane lokacije"
src={selectedMapEmbedUrl}
className="h-72 w-full rounded-lg border border-border-hairline"
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-5 py-4">
<button
type="button"
onClick={() => setMapPickerOpen(false)}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
Odustani
</button>
<button
type="button"
disabled={!selectedLocation}
onClick={() => {
setForm((prev) => ({ ...prev, location: selectedLocation.display_name || prev.location }));
setMapPickerOpen(false);
}}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Pohrani lokaciju
</button>
</div>
</div>
</ModalShell>
)}
</>
);
}

View File

@@ -0,0 +1,114 @@
import { useEffect } from 'preact/hooks';
import { useSignal } from '@preact/signals';
import { fetchJson } from '../../lib/api';
import { getCachedData, setCachedData } from '../../lib/db';
import { useToast } from '../../hooks/useToast';
type LoadState = 'idle' | 'loading' | 'success' | 'error';
export interface DataViewProps {
endpoint: string;
cacheKey: string;
initialData: unknown[] | null;
initialError?: string | null;
}
export default function DataView({
endpoint,
cacheKey,
initialData,
initialError = null,
}: DataViewProps) {
const toast = useToast();
const itemsSignal = useSignal<unknown[]>([]);
const stateSignal = useSignal<LoadState>('idle');
const sourceSignal = useSignal<'ssr' | 'network' | 'cache' | null>(null);
const errorSignal = useSignal<string | null>(null);
useEffect(() => {
if (Array.isArray(initialData) && initialData.length) {
itemsSignal.value = initialData;
stateSignal.value = 'success';
sourceSignal.value = 'ssr';
} else if (initialError) {
errorSignal.value = initialError;
stateSignal.value = 'error';
} else {
stateSignal.value = 'loading';
}
const controller = new AbortController();
// SSR gives first paint; hydration then refreshes client data and persists it offline.
async function refreshData() {
try {
stateSignal.value = 'loading';
const payload = await fetchJson<unknown[]>(endpoint, {
method: 'GET',
signal: controller.signal,
timeoutMs: 12000,
});
itemsSignal.value = Array.isArray(payload) ? payload : [];
await setCachedData(cacheKey, itemsSignal.value);
sourceSignal.value = 'network';
errorSignal.value = null;
stateSignal.value = 'success';
toast.success('Podaci su uspješno osvježeni.');
} catch (error) {
if (controller.signal.aborted) {
return;
}
const cached = await getCachedData<unknown[]>(cacheKey);
if (cached?.payload) {
itemsSignal.value = Array.isArray(cached.payload) ? cached.payload : [];
sourceSignal.value = 'cache';
errorSignal.value = null;
stateSignal.value = 'success';
toast.warning('Prikazani su lokalno spremljeni podaci (offline fallback).');
return;
}
errorSignal.value = error instanceof Error ? error.message : 'Data loading failed.';
stateSignal.value = 'error';
toast.error('Neuspješno učitavanje podataka i nema lokalnog cachea.');
}
}
void refreshData();
return () => controller.abort();
}, [endpoint, cacheKey, initialData, initialError]);
return (
<section className="rounded-xl border border-border-hairline bg-canvas-elevated p-5 shadow-sm">
<header className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-main">DataView</h2>
<span className="text-xs text-text-muted">
Source: {sourceSignal.value || 'unknown'} State: {stateSignal.value}
</span>
</header>
{stateSignal.value === 'loading' && (
<p className="text-sm text-text-muted">Učitavanje podataka...</p>
)}
{stateSignal.value === 'error' && (
<p className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{errorSignal.value || 'Greška pri učitavanju.'}
</p>
)}
{stateSignal.value === 'success' && (
<div className="space-y-2">
<p className="text-sm text-text-muted">
Ukupno zapisa: <strong className="text-text-main">{itemsSignal.value.length}</strong>
</p>
<pre className="max-h-96 overflow-auto rounded-lg border border-border-hairline bg-canvas-base p-3 text-xs text-text-main">
{JSON.stringify(itemsSignal.value.slice(0, 20), null, 2)}
</pre>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,247 @@
// src/components/fleet/ServicePhotoUpload.jsx
// Forma za upload fotografija na servisni zapis (POST /api/fleet/service-photos/)
// Kritično: AbortController prekida upload ako korisnik zatvori modal ili pokrene novi upload.
// Backend prima multipart/form-data — apiClient detektira FormData i ne dodaje Content-Type header.
import { useState, useEffect, useRef } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $isOffline } from '../../stores/networkStore.js';
import { uploadServicePhoto } from '../../stores/fleetDashboardStore.js';
import { showToast } from '../../stores/toastStore.js';
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
export default function ServicePhotoUpload({ open, serviceRecordId, onClose, onSuccess }) {
const isOffline = useStore($isOffline);
const [images, setImages] = useState([]);
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [error, setError] = useState('');
const [isMounted, setIsMounted] = useState(false);
// AbortController ref — prekinuti tekući upload ako korisnik napusti ili pokrene novi
const abortRef = useRef(null);
useEffect(() => {
setIsMounted(true);
return () => {
// Prekid uploadova pri unmount-u (navigacija, hot reload)
abortRef.current?.abort();
};
}, []);
useEffect(() => {
if (!open) {
abortRef.current?.abort();
abortRef.current = null;
setImages([]);
setDescription('');
setError('');
setSubmitting(false);
setProgress({ current: 0, total: 0 });
}
}, [open]);
if (!open || !isMounted) return null;
const handleFileChange = (e) => {
const files = Array.from(e.target.files ?? []);
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES);
const invalidType = files.filter((f) => !ACCEPTED_TYPES.includes(f.type));
if (oversized.length > 0) {
setError(`Sljedeće datoteke prelaze ${MAX_FILE_SIZE_MB} MB: ${oversized.map((f) => f.name).join(', ')}`);
return;
}
if (invalidType.length > 0) {
setError(`Nepodržan format: ${invalidType.map((f) => f.name).join(', ')}. Koristite JPG, PNG, WEBP ili HEIC.`);
return;
}
setError('');
setImages(files);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!serviceRecordId) {
setError('Nije poznat servisni zapis za koji se upload vrši.');
return;
}
if (images.length === 0) {
setError('Odaberite barem jednu fotografiju.');
return;
}
if (isOffline) {
showToast('Upload fotografija nije dostupan u offline modu.', 'error');
return;
}
// Prekid prethodnog aktivnog uploadovaja (dupli klik zaštita)
abortRef.current?.abort();
abortRef.current = new AbortController();
const { signal } = abortRef.current;
setSubmitting(true);
setProgress({ current: 0, total: images.length });
let successCount = 0;
try {
for (const [index, file] of images.entries()) {
if (signal.aborted) break;
setProgress({ current: index + 1, total: images.length });
await uploadServicePhoto(serviceRecordId, file, description, signal);
successCount++;
}
if (!signal.aborted) {
showToast(`${successCount} fotografija je uspješno uploadano.`, 'success');
e.target.reset();
setImages([]);
setDescription('');
onSuccess?.();
onClose?.();
}
} catch (err) {
if (err?.name === 'AbortError') {
// Tiho — korisnik je sam prekinuo, ne prikazujemo grešku
return;
}
setError(err?.message || 'Greška pri uploadu fotografija.');
if (successCount > 0) {
showToast(`${successCount} od ${images.length} fotografija uploadano.`, 'warning');
}
} finally {
if (!signal?.aborted) {
setSubmitting(false);
setProgress({ current: 0, total: 0 });
abortRef.current = null;
}
}
};
const handleCancel = () => {
abortRef.current?.abort();
abortRef.current = null;
onClose?.();
};
return (
<ModalShell
onClose={handleCancel}
overlayClassName="z-50 p-4"
contentClassName="flex items-center justify-center min-h-full"
panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Upload fotografija</h3>
<p className="text-xs text-text-muted mt-0.5">
Servisni zapis #{serviceRecordId}
</p>
</div>
<button
type="button"
onClick={handleCancel}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted uppercase tracking-wide">
Fotografije kvara / radova ({images.length} odabrano)
</label>
<input
type="file"
accept={ACCEPTED_TYPES.join(',')}
multiple
onChange={handleFileChange}
disabled={submitting}
className="block w-full text-xs text-text-muted file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-mono file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 file:cursor-pointer transition-all disabled:opacity-50"
/>
<p className="mt-1 text-[11px] text-text-muted">
JPG, PNG, WEBP, HEIC max {MAX_FILE_SIZE_MB} MB po datoteci
</p>
</div>
{/* Preview odabranih datoteka */}
{images.length > 0 && (
<div className="rounded-lg border border-border-hairline bg-canvas-deep p-2 max-h-32 overflow-y-auto space-y-1">
{images.map((file, i) => (
<p key={i} className="text-[10px] font-mono text-text-muted truncate">
📎 [{i + 1}] {file.name}{' '}
<span className="text-text-muted/60">
({(file.size / 1024).toFixed(1)} KB)
</span>
</p>
))}
</div>
)}
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis (primjenjuje se na sve slike)</span>
<input
type="text"
value={description}
onInput={(e) => setDescription(e.currentTarget.value)}
disabled={submitting}
placeholder="Npr. Oštećenje desnog prednjeg felge, kvar pumpe..."
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
/>
</label>
{/* Traka napretka pri višestrukom uploadu */}
{submitting && progress.total > 1 && (
<div>
<div className="flex justify-between text-[11px] text-text-muted mb-1">
<span>Uploading...</span>
<span>{progress.current}/{progress.total}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-canvas-deep overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
<button
type="button"
onClick={handleCancel}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
{submitting ? 'Prekini upload' : 'Odustani'}
</button>
<button
type="submit"
disabled={submitting || images.length === 0 || isOffline}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting
? `Uploading ${progress.current}/${progress.total}...`
: `Upload ${images.length > 0 ? images.length + ' slike' : 'fotografija'}`}
</button>
</div>
</form>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,11 @@
export default function ServiceRecordCreateButton({ onClick, className = '', label = '+ Servisni zapis' }) {
return (
<button
type="button"
onClick={onClick}
className={`btn-erp rounded-md bg-emerald-600 px-3 py-1 text-xs font-semibold normal-case tracking-normal text-white hover:bg-emerald-700 ${className}`.trim()}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,537 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import {
downloadServiceRecordPdf,
fetchTeamMembers,
fetchServiceRecordFiles,
sendServiceRecordEmail,
updateServiceRecord,
} from '../../stores/fleetDashboardStore';
import { showToast } from '../../stores/toastStore';
import ModalShell from '../ui/ModalShell';
import WorkOrderImageCarousel, { resolveMediaUrl } from '../dashboard/WorkOrderImageCarousel';
import { formatEntityCode } from '../../lib/displayIds';
function formatDate(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString('hr-HR');
}
function formatCost(value) {
const numeric = Number(value);
if (Number.isNaN(numeric)) return '-';
return `${numeric.toFixed(2)} EUR`;
}
function resolveFileUrl(pathOrUrl) {
if (!pathOrUrl) return '#';
return resolveMediaUrl(pathOrUrl);
}
export default function ServiceRecordDetailModal({
open,
serviceRecord,
onClose,
onUpdated,
onBack = null,
backLabel = 'Natrag',
}) {
const [files, setFiles] = useState({ photos: [], attachments: [] });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [emailSending, setEmailSending] = useState(false);
const [widgetError, setWidgetError] = useState('');
const [emailModalOpen, setEmailModalOpen] = useState(false);
const [teamMembers, setTeamMembers] = useState([]);
const [loadingTeamMembers, setLoadingTeamMembers] = useState(false);
const [selectedTeamMemberIds, setSelectedTeamMemberIds] = useState([]);
const [sendToAllTeamMembers, setSendToAllTeamMembers] = useState(false);
const [emailPayload, setEmailPayload] = useState({
subject: '',
message: '',
});
const [editMode, setEditMode] = useState(false);
const [editSaving, setEditSaving] = useState(false);
const [editForm, setEditForm] = useState({
service_title: '',
description: '',
parts: '',
cost: '',
});
useEffect(() => {
if (!open || typeof window === 'undefined') return;
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [open]);
useEffect(() => {
if (!open || !serviceRecord?.id) {
setFiles({ photos: [], attachments: [] });
setError('');
setLoading(false);
setEmailSending(false);
setWidgetError('');
setEmailModalOpen(false);
setTeamMembers([]);
setLoadingTeamMembers(false);
setSelectedTeamMemberIds([]);
setSendToAllTeamMembers(false);
setEmailPayload({ subject: '', message: '' });
setEditMode(false);
setEditSaving(false);
setEditForm({ service_title: '', description: '', parts: '', cost: '' });
return;
}
let cancelled = false;
setLoading(true);
setError('');
setEditMode(false);
setEditSaving(false);
setEditForm({
service_title: serviceRecord.service_title || '',
description: serviceRecord.description || '',
parts: serviceRecord.parts || '',
cost: serviceRecord.cost == null ? '' : String(serviceRecord.cost),
});
(async () => {
try {
const result = await fetchServiceRecordFiles(serviceRecord.id);
if (!cancelled) {
setFiles(result);
}
} catch (err) {
if (!cancelled) {
setError(err?.message || 'Neuspješno dohvaćanje datoteka servisnog zapisa.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [open, serviceRecord?.id]);
useEffect(() => {
if (!emailModalOpen) return;
let cancelled = false;
setLoadingTeamMembers(true);
(async () => {
try {
const members = await fetchTeamMembers();
if (!cancelled) {
setTeamMembers(members);
}
} catch (err) {
if (!cancelled) {
setWidgetError(err?.message || 'Neuspješno dohvaćanje članova tima.');
}
} finally {
if (!cancelled) {
setLoadingTeamMembers(false);
}
}
})();
return () => {
cancelled = true;
};
}, [emailModalOpen]);
const totalFiles = useMemo(
() => (files.photos?.length || 0) + (files.attachments?.length || 0),
[files]
);
const handleDownloadPdf = async () => {
setWidgetError('');
try {
await downloadServiceRecordPdf(serviceRecord.id);
} catch (err) {
setWidgetError(err?.message || 'Neuspješno preuzimanje PDF-a servisnog zapisa.');
}
};
const handleSendEmail = async () => {
setWidgetError('');
setEmailSending(true);
try {
const recipients = sendToAllTeamMembers
? teamMembers.map((member) => member.email).filter(Boolean)
: teamMembers
.filter((member) => selectedTeamMemberIds.includes(String(member.id)))
.map((member) => member.email)
.filter(Boolean);
if (recipients.length === 0) {
setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
return;
}
let failed = 0;
for (const recipient of recipients) {
try {
await sendServiceRecordEmail(
serviceRecord.id,
{
recipient,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
},
{ toast: false }
);
showToast(`Email je poslan: ${recipient}`, 'success');
} catch {
failed += 1;
showToast(`Neuspješno slanje: ${recipient}`, 'error');
}
}
if (failed > 0) {
setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
return;
}
setEmailModalOpen(false);
} catch (err) {
setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
} finally {
setEmailSending(false);
}
};
const handleSaveEdit = async () => {
setWidgetError('');
setEditSaving(true);
try {
const costValue = editForm.cost === '' ? '0.00' : Number(editForm.cost).toFixed(2);
if (Number.isNaN(Number(costValue))) {
setWidgetError('Trošak mora biti valjan decimalni broj.');
return;
}
const updated = await updateServiceRecord(serviceRecord.id, {
service_title: editForm.service_title.trim(),
description: editForm.description.trim(),
parts: editForm.parts.trim(),
cost: costValue,
});
setEditMode(false);
onUpdated?.(updated);
} catch (err) {
setWidgetError(err?.message || 'Neuspješno ažuriranje servisnog zapisa.');
} finally {
setEditSaving(false);
}
};
if (!open || !serviceRecord) return null;
return (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="flex w-full max-w-3xl flex-col rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-6rem)]"
>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Detalji servisnog zapisa</h3>
<p className="text-xs text-text-muted">
ID: {formatEntityCode('SR', serviceRecord.id)} Datum: {formatDate(serviceRecord.service_date)} Datoteke: {totalFiles}
</p>
</div>
<div className="flex items-center gap-2">
{onBack && (
<button
type="button"
onClick={onBack}
disabled={editMode || editSaving}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
{backLabel}
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main">
<p className="text-xs text-text-muted">Naziv servisnog zapisa</p>
<p>{serviceRecord.service_title || '-'}</p>
<p className="mt-1 text-xs text-text-muted">{serviceRecord.description || '-'}</p>
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main">
<p className="text-xs text-text-muted">Trošak / KM / Dizalica</p>
<p>
{formatCost(serviceRecord.cost)} {serviceRecord.mileage ?? '-'} km {serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
</p>
</div>
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Dokument i email</h4>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleDownloadPdf}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Preuzmi PDF
</button>
<button
type="button"
onClick={() => {
setWidgetError('');
setEmailModalOpen(true);
}}
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700"
>
Pošalji email
</button>
</div>
{widgetError && (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{widgetError}
</div>
)}
</div>
{loading && (
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-3 text-sm text-text-muted">
Učitavanje datoteka...
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{!loading && !error && (
<div className="space-y-4">
<div className="overflow-hidden rounded-lg border border-border-hairline bg-canvas-base">
<WorkOrderImageCarousel
images={files.photos.map((item) => item.optimized_url || item.image).filter(Boolean)}
emptyLabel="Nema slika za ovaj servisni zapis"
/>
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Dokumenti / prilozi</h4>
{files.attachments.length === 0 ? (
<p className="text-xs text-text-muted">Nema priloga.</p>
) : (
<ul className="space-y-2">
{files.attachments.map((item) => (
<li key={item.id} className="text-sm">
<a
href={resolveFileUrl(item.file)}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800 hover:underline"
>
📎 {item.file?.split('/').pop() || `Prilog ${item.id}`}
</a>
</li>
))}
</ul>
)}
</div>
{editMode && (
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Uredi servisni zapis</h4>
<div className="space-y-3">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naziv servisnog zapisa</span>
<input
type="text"
value={editForm.service_title}
onInput={(event) => setEditForm((prev) => ({ ...prev, service_title: event.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis</span>
<textarea
rows={3}
value={editForm.description}
onInput={(event) => setEditForm((prev) => ({ ...prev, description: event.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Korišteni dijelovi</span>
<textarea
rows={2}
value={editForm.parts}
onInput={(event) => setEditForm((prev) => ({ ...prev, parts: event.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trošak (EUR)</span>
<input
type="number"
min="0"
step="0.01"
value={editForm.cost}
onInput={(event) => setEditForm((prev) => ({ ...prev, cost: event.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main"
/>
</label>
</div>
</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-5 py-3">
{!editMode && (
<button
type="button"
onClick={() => setEditMode(true)}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Uredi servisni zapis
</button>
)}
{editMode && (
<>
<button
type="button"
onClick={() => {
setEditMode(false);
setEditForm({
service_title: serviceRecord.service_title || '',
description: serviceRecord.description || '',
parts: serviceRecord.parts || '',
cost: serviceRecord.cost == null ? '' : String(serviceRecord.cost),
});
}}
disabled={editSaving}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Odustani
</button>
<button
type="button"
onClick={handleSaveEdit}
disabled={editSaving}
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{editSaving ? 'Spremam…' : 'Spremi izmjene'}
</button>
</>
)}
<button
type="button"
onClick={onClose}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Zatvori
</button>
</div>
</ModalShell>
{emailModalOpen && (
<ModalShell
onClose={() => setEmailModalOpen(false)}
overlayClassName="z-[60] p-4"
contentClassName="flex items-center justify-center min-h-full"
panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h4 className="text-sm font-semibold text-text-main">Pošalji email za servisni zapis</h4>
<button
type="button"
onClick={() => setEmailModalOpen(false)}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori email modal"
>
</button>
</div>
<div className="space-y-3 px-4 py-3">
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={sendToAllTeamMembers}
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
/>
Pošalji svim članovima tima
</label>
<select
multiple
disabled={sendToAllTeamMembers || loadingTeamMembers}
value={selectedTeamMemberIds}
onChange={(event) => {
const nextIds = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
setSelectedTeamMemberIds(nextIds);
}}
className="min-h-28 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
>
{!loadingTeamMembers && teamMembers.length === 0 && (
<option disabled>Nema dostupnih članova tima</option>
)}
{teamMembers.map((member) => (
<option key={member.id} value={String(member.id)}>
{member.full_name} ({member.email})
</option>
))}
</select>
<p className="text-xs text-text-muted">
{loadingTeamMembers
? 'Učitavanje članova tima...'
: `Odabrano: ${sendToAllTeamMembers ? teamMembers.length : selectedTeamMemberIds.length}`}
</p>
<input
type="text"
value={emailPayload.subject}
onInput={(event) => setEmailPayload({ ...emailPayload, subject: event.currentTarget.value })}
placeholder="Naslov (opcionalno)"
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
/>
<textarea
value={emailPayload.message}
onInput={(event) => setEmailPayload({ ...emailPayload, message: event.currentTarget.value })}
placeholder="Poruka (opcionalno)"
className="min-h-24 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
/>
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
<button
type="button"
onClick={() => setEmailModalOpen(false)}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Odustani
</button>
<button
type="button"
onClick={handleSendEmail}
disabled={emailSending}
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{emailSending ? 'Šaljem…' : 'Pošalji'}
</button>
</div>
</div>
</ModalShell>
)}
</>
);
}

View File

@@ -0,0 +1,374 @@
// src/components/fleet/ServiceRecordForm.jsx
// Forma za kreiranje novog servisnog zapisa (POST /api/fleet/service-records/)
// Koristi offline-first pattern iz fleetDashboardStore: createServiceRecord().
import { useState, useEffect, useRef } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $isOffline } from '../../stores/networkStore.js';
import { createServiceRecord, uploadServicePhoto, uploadServiceAttachment } from '../../stores/fleetDashboardStore.js';
import { showToast } from '../../stores/toastStore.js';
const INITIAL = {
task: '',
description: '',
parts: '',
cost: '',
};
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
const ACCEPTED_DOCUMENT_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'application/zip',
];
const ACCEPTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt', '.zip'];
export default function ServiceRecordForm({
open,
assignedCrane,
availableTasks = [],
onClose,
onSuccess,
onBack = null,
backLabel = 'Natrag',
}) {
const isOffline = useStore($isOffline);
const [form, setForm] = useState(INITIAL);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [isMounted, setIsMounted] = useState(false);
const [attachments, setAttachments] = useState([]);
const [attachmentDescription, setAttachmentDescription] = useState('');
const [uploadProgress, setUploadProgress] = useState({ current: 0, total: 0 });
const uploadAbortRef = useRef(null);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!open || typeof window === 'undefined') return;
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [open]);
useEffect(() => {
if (!open) {
setForm(INITIAL);
setError('');
setSubmitting(false);
setAttachments([]);
setAttachmentDescription('');
setUploadProgress({ current: 0, total: 0 });
uploadAbortRef.current?.abort();
uploadAbortRef.current = null;
return;
}
// Predaberi jedini dostupni task ako postoji samo jedan
if (availableTasks.length === 1) {
setForm((prev) => ({ ...prev, task: String(availableTasks[0].id) }));
}
}, [open, assignedCrane, availableTasks]);
useEffect(() => {
if (!form.task) return;
const exists = availableTasks.some((task) => String(task.id) === String(form.task));
if (!exists) {
setForm((prev) => ({ ...prev, task: '' }));
}
}, [availableTasks, form.task]);
if (!open || !isMounted) return null;
const field = (name) => (e) => setForm((prev) => ({ ...prev, [name]: e.currentTarget.value }));
const handleFileChange = (event) => { const files = Array.from(event.currentTarget.files ?? []);
if (files.length === 0) {
setAttachments([]);
return;
}
const oversized = files.filter((file) => file.size > MAX_FILE_SIZE_BYTES);
if (oversized.length > 0) {
setError(`Datoteka prelazi ${MAX_FILE_SIZE_MB} MB: ${oversized[0].name}`);
return;
}
const invalidType = files.filter((file) => {
const lowerName = String(file.name || '').toLowerCase();
const byMime = ACCEPTED_IMAGE_TYPES.includes(file.type) || ACCEPTED_DOCUMENT_TYPES.includes(file.type);
const byExtension = ACCEPTED_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
return !(byMime || byExtension);
});
if (invalidType.length > 0) {
setError(`Nepodržan format: ${invalidType[0].name}. Podržano: JPG, PNG, WEBP, HEIC, PDF, DOC, DOCX, XLS, XLSX, TXT, ZIP.`);
return;
}
setError('');
setAttachments(files);
};
const handleSubmit = async (e) => {
e.preventDefault();
const formElement = e.currentTarget;
setError('');
if (!assignedCrane) {
setError('Nije pronađena dodijeljena dizalica. Obratite se administratoru.');
return;
}
if (!form.task) {
setError('Za novi servisni zapis morate odabrati radni zadatak.');
return;
}
if (!form.description.trim()) {
setError('Opis posla je obavezan.');
return;
}
const cost = form.cost === '' ? '0.00' : parseFloat(form.cost).toFixed(2);
if (Number.isNaN(Number(cost))) {
setError('Trošak mora biti valjan decimalni broj.');
return;
}
setSubmitting(true);
try {
const payload = {
task: form.task,
description: form.description.trim(),
parts: form.parts.trim(),
cost,
};
const createdRecord = await createServiceRecord(payload);
if (attachments.length > 0) {
if (isOffline || String(createdRecord.id).startsWith('offline-')) {
setError('Upload datoteka nije dostupan u offline modu. Spremite zapis pa uploadajte datoteke kada ste online.');
setSubmitting(false);
return;
}
uploadAbortRef.current?.abort();
uploadAbortRef.current = new AbortController();
const signal = uploadAbortRef.current.signal;
setUploadProgress({ current: 0, total: attachments.length });
for (const [index, file] of attachments.entries()) {
setUploadProgress({ current: index + 1, total: attachments.length });
const isImage = ACCEPTED_IMAGE_TYPES.includes(file.type) || /\.(jpe?g|png|webp|heic)$/i.test(file.name || '');
if (isImage) {
await uploadServicePhoto(createdRecord.id, file, attachmentDescription, signal);
} else {
await uploadServiceAttachment(createdRecord.id, file, attachmentDescription, signal);
}
}
}
showToast('Servisni zapis je uspješno kreiran.', 'success');
formElement?.reset?.();
setAttachments([]);
setAttachmentDescription('');
setUploadProgress({ current: 0, total: 0 });
onSuccess?.();
onClose?.();
} catch (err) {
if (err?.name !== 'AbortError') {
setError(err?.message || 'Greška pri slanju servisnog zapisa.');
}
} finally {
setSubmitting(false);
uploadAbortRef.current = null;
}
};
const disabled = submitting || isOffline;
return (
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto px-4 pb-4 pt-20"
contentClassName="flex items-start justify-center min-h-full"
panelClassName={`w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)] transition-opacity ${isOffline ? 'opacity-60' : ''}`}
>
<div className="w-full flex flex-col">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Novi servisni zapis</h3>
{isOffline && (
<p className="text-xs text-amber-600 font-medium mt-0.5">
Offline mod zapis će se sinkronizirati kad se veza vrati
</p>
)}
</div>
<div className="flex items-center gap-2">
{onBack && (
<button
type="button"
onClick={onBack}
disabled={submitting}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
{backLabel}
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
{/* Dizalica — read-only */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Dizalica</span>
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main">
{assignedCrane
? `${assignedCrane.registration_number}${assignedCrane.make ?? ''} ${assignedCrane.model ?? ''}`
: 'Nema dodijeljene dizalice'}
</div>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni zadatak *</span>
<select
value={form.task}
onChange={(event) => setForm((prev) => ({ ...prev, task: event.currentTarget.value }))}
disabled={disabled}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
required
>
<option value=""> Odaberi radni zadatak </option>
{availableTasks.map((task) => (
<option key={task.id} value={String(task.id)}>
{task.title} {task.status || 'aktivan'}
{task.work_order_label ? `${task.work_order_label}` : ''}
</option>
))}
</select>
{availableTasks.length === 0 && (
<span className="text-[11px] text-amber-600">
Nema dostupnih radnih zadataka za odabranu dizalicu. Kreirajte novi radni zadatak.
</span>
)}
</label>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis posla *</span>
<textarea
rows={3}
value={form.description}
onInput={field('description')}
disabled={disabled}
placeholder="Opis izvršenih radova, zamijenjenih dijelova, uočenih kvarova..."
className="min-h-20 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50 resize-none"
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Korišteni dijelovi</span>
<textarea
rows={2}
value={form.parts}
onInput={field('parts')}
disabled={disabled}
placeholder="Npr. Filter ulja, zupčasti remen, 5L motorno ulje 5W-40..."
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50 resize-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trošak (EUR)</span>
<input
type="number"
min="0"
step="0.01"
value={form.cost}
onInput={field('cost')}
disabled={disabled}
placeholder="0.00"
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
<div className="space-y-2 rounded-lg border border-border-hairline bg-canvas-base px-3 py-3">
<p className="text-sm font-medium text-text-main">Slike / datoteke uz servis</p>
<input
type="file"
accept={ACCEPTED_EXTENSIONS.join(',')}
multiple
onChange={handleFileChange}
disabled={disabled}
className="block w-full text-xs text-text-muted file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-indigo-700 hover:file:bg-indigo-100 disabled:opacity-50"
/>
<p className="text-[11px] text-text-muted">
Možete odabrati više datoteka (JPG/PNG/WEBP/HEIC/PDF/DOC/DOCX/XLS/XLSX/TXT/ZIP), max {MAX_FILE_SIZE_MB} MB po datoteci.
</p>
{attachments.length > 0 && (
<div className="max-h-24 overflow-y-auto rounded border border-border-hairline bg-canvas-elevated p-2">
{attachments.map((file, index) => (
<p key={index} className="truncate text-[11px] text-text-muted">
📎 {file.name}
</p>
))}
</div>
)}
<input
type="text"
value={attachmentDescription}
onInput={(event) => setAttachmentDescription(event.currentTarget.value)}
disabled={disabled}
placeholder="Opis koji se primjenjuje na sve odabrane datoteke"
className="w-full rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main placeholder-text-muted/40 disabled:opacity-50"
/>
{submitting && uploadProgress.total > 1 && uploadProgress.current > 0 && (
<p className="text-[11px] text-text-muted">
Upload datoteka: {uploadProgress.current}/{uploadProgress.total}
</p>
)}
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep disabled:opacity-50"
>
Odustani
</button>
<button
type="submit"
disabled={disabled || !assignedCrane}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremam...' : isOffline ? 'Spremi offline' : 'Spremi zapis'}
</button>
</div>
</form>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,258 @@
// src/components/invoicing/InvoiceForm.jsx
// Forma za kreiranje fakture s dinamičkim stavkama (POST /api/invoicing/invoices/)
// Backend prima JSON body s nested 'items' poljem: [{description, quantity, unit_price}]
// NIJE offline-queued jer fakture zahtijevaju strogu konzistentnost s backendom.
import { useState, useEffect } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { $isOffline } from '../../stores/networkStore.js';
import { $clients, $invoicesLoading, fetchClients, createInvoice } from '../../stores/invoiceStore.js';
import { showToast } from '../../stores/toastStore.js';
const EMPTY_ITEM = { description: '', quantity: '1', unit_price: '' };
const today = () => {
const d = new Date();
d.setDate(d.getDate() + 30); // Default rok: 30 dana od danas
return d.toISOString().slice(0, 10);
};
export default function InvoiceForm({ onSuccess }) {
const isOffline = useStore($isOffline);
const clients = useStore($clients);
const loading = useStore($invoicesLoading);
const [isMounted, setIsMounted] = useState(false);
const [clientId, setClientId] = useState('');
const [dueDate, setDueDate] = useState(today());
const [items, setItems] = useState([{ ...EMPTY_ITEM }]);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
setIsMounted(true);
if ($clients.get().length === 0) {
fetchClients();
}
}, []);
if (!isMounted) {
return (
<div className="h-96 rounded-2xl border border-border-hairline bg-canvas-elevated animate-pulse" />
);
}
// ---- Item helpers ----
const setItemField = (index, field, value) => {
setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))
);
};
const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
const removeItem = (index) => {
if (items.length === 1) return; // Minimalno jedna stavka
setItems((prev) => prev.filter((_, i) => i !== index));
};
// Ukupni iznos — izračun u frontendu za prikaz
const total = items.reduce((sum, item) => {
const qty = parseFloat(item.quantity) || 0;
const price = parseFloat(item.unit_price) || 0;
return sum + qty * price;
}, 0);
// ---- Submit ----
const handleSubmit = async (e) => {
e.preventDefault();
if (isOffline) {
showToast('Faktura se ne može kreirati u offline modu.', 'error');
return;
}
if (!clientId) {
showToast('Odaberite klijenta.', 'error');
return;
}
const validItems = items.filter(
(item) => item.description.trim() && parseFloat(item.quantity) > 0 && parseFloat(item.unit_price) > 0
);
if (validItems.length === 0) {
showToast('Dodajte barem jednu stavku s opisom, količinom i cijenom.', 'error');
return;
}
setSubmitting(true);
try {
const payload = {
client: Number(clientId),
due_date: dueDate,
items: validItems.map((item) => ({
description: item.description.trim(),
quantity: parseFloat(item.quantity),
unit_price: parseFloat(item.unit_price),
})),
};
const invoice = await createInvoice(payload);
setClientId('');
setDueDate(today());
setItems([{ ...EMPTY_ITEM }]);
onSuccess?.(invoice);
} catch (err) {
if (err?.name !== 'AbortError') {
showToast(err?.message || 'Greška pri kreiranju fakture.', 'error');
}
} finally {
setSubmitting(false);
}
};
const disabled = submitting || isOffline;
return (
<div
className={`bg-canvas-elevated border border-border-hairline p-6 rounded-2xl shadow-[0_8px_30px_rgba(0,0,0,0.15)] transition-all ${
isOffline ? 'opacity-40 pointer-events-none' : ''
}`}
>
<h3 className="text-xs font-mono tracking-widest text-text-main uppercase border-b border-border-hairline pb-2 mb-5">
// NOVA_FAKTURA
</h3>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Klijent + rok */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
Klijent <span className="text-red-500">*</span>
</label>
<select
value={clientId}
onChange={(e) => setClientId(e.currentTarget.value)}
disabled={disabled || loading}
required
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none transition-all disabled:opacity-50"
>
<option value="">
{loading ? 'Učitavam klijente...' : '— Odaberi klijenta —'}
</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.tax_id})
</option>
))}
</select>
</div>
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
Rok plaćanja <span className="text-red-500">*</span>
</label>
<input
type="date"
value={dueDate}
onInput={(e) => setDueDate(e.currentTarget.value)}
disabled={disabled}
required
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none transition-all disabled:opacity-50"
/>
</div>
</div>
{/* Stavke fakture */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[11px] font-semibold text-text-muted">
Stavke fakture <span className="text-red-500">*</span>
</label>
<button
type="button"
onClick={addItem}
disabled={disabled}
className="text-[11px] font-mono text-indigo-600 hover:text-indigo-800 disabled:opacity-50"
>
+ Dodaj stavku
</button>
</div>
{/* Zaglavlje tablice */}
<div className="hidden sm:grid grid-cols-[1fr_80px_100px_32px] gap-2 mb-1 px-1">
{['Opis stavke', 'Kol.', 'Cijena (EUR)', ''].map((h) => (
<span key={h} className="text-[10px] font-semibold text-text-muted uppercase tracking-wide">
{h}
</span>
))}
</div>
<div className="space-y-2">
{items.map((item, index) => (
<div key={index} className="grid grid-cols-[1fr_80px_100px_32px] gap-2 items-start">
<input
type="text"
value={item.description}
onInput={(e) => setItemField(index, 'description', e.currentTarget.value)}
disabled={disabled}
placeholder="Naziv usluge / proizvoda"
className="px-2.5 py-1.5 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main placeholder-text-muted/40 focus:border-indigo-500 focus:outline-none disabled:opacity-50"
required
/>
<input
type="number"
min="0.01"
step="0.01"
value={item.quantity}
onInput={(e) => setItemField(index, 'quantity', e.currentTarget.value)}
disabled={disabled}
className="px-2.5 py-1.5 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main focus:border-indigo-500 focus:outline-none disabled:opacity-50"
required
/>
<input
type="number"
min="0.01"
step="0.01"
value={item.unit_price}
onInput={(e) => setItemField(index, 'unit_price', e.currentTarget.value)}
disabled={disabled}
placeholder="0.00"
className="px-2.5 py-1.5 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main placeholder-text-muted/40 focus:border-indigo-500 focus:outline-none disabled:opacity-50"
required
/>
<button
type="button"
onClick={() => removeItem(index)}
disabled={disabled || items.length === 1}
className="h-[34px] flex items-center justify-center rounded-lg text-text-muted hover:text-red-600 hover:bg-red-50 disabled:opacity-30 transition-colors"
aria-label="Ukloni stavku"
>
×
</button>
</div>
))}
</div>
</div>
{/* Ukupni iznos */}
<div className="flex justify-end">
<div className="rounded-lg border border-border-hairline bg-canvas-deep px-4 py-2 text-sm">
<span className="text-text-muted">Ukupno (bez PDV-a): </span>
<span className="font-bold text-text-main">
{total.toLocaleString('hr-HR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} EUR
</span>
</div>
</div>
<button
type="submit"
disabled={disabled}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white text-xs py-2.5 rounded-lg font-semibold transition-all duration-200"
>
{submitting ? 'Kreiranje fakture...' : 'Kreiraj fakturu'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,239 @@
import { useMemo } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, downloadWorkOrderServiceRecordsPdf } from '../../stores/fleetDashboardStore';
function formatTimestamp(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('hr-HR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function detectNotificationKind(notification) {
const title = String(notification?.title || '').toLowerCase();
if (title.includes('servisni kontekst')) return 'service_context';
if (title.includes('pdf')) return 'work_order_pdf';
if (title.includes('putni nalog kreiran')) return 'work_order_created';
if (title.includes('novi putni nalog')) return 'work_order_assigned';
if (title.includes('putni nalog zatvoren')) return 'work_order_closed';
if (title.includes('putni nalog obrisan')) return 'work_order_deleted';
if (title.includes('servis zabilježen')) return 'service_record_created';
if (title.includes('servis na vašem vozilu')) return 'service_record_vehicle_update';
if (title.includes('uskoro servis')) return 'service_due_warning';
return 'generic';
}
function getEntityAction(notification, meta) {
const metadata = notification?.metadata || {};
if (metadata.entity_type === 'service_context' && metadata.vehicle_id) {
return {
label: 'Otvori servisni kontekst',
run() {
window.dispatchEvent(new CustomEvent('notification:open-entity', {
detail: {
entityType: 'service_context',
vehicleId: metadata.vehicle_id,
section: metadata.section || meta.section,
},
}));
},
};
}
if (
metadata.entity_type === 'work_order_pdf' &&
metadata.stage === 'completed' &&
metadata.work_order_id
) {
return {
label: metadata.pdf_type === 'invoices'
? 'Preuzmi PDF računa'
: metadata.pdf_type === 'service_records'
? 'Preuzmi PDF servisnih zapisa'
: 'Preuzmi PDF putnog naloga',
run() {
if (metadata.pdf_type === 'invoices') {
return downloadWorkOrderInvoicesPdf(metadata.work_order_id);
}
if (metadata.pdf_type === 'service_records') {
return downloadWorkOrderServiceRecordsPdf(metadata.work_order_id);
}
return downloadWorkOrderPdf(metadata.work_order_id);
},
};
}
if (metadata.entity_type === 'work_order' && metadata.work_order_id) {
return {
label: 'Otvori detalje naloga',
run() {
window.dispatchEvent(new CustomEvent('notification:open-entity', {
detail: {
entityType: 'work_order',
entityId: metadata.work_order_id,
vehicleId: metadata.vehicle_id || null,
section: metadata.section || meta.section,
},
}));
},
};
}
if (metadata.entity_type === 'service_record' && metadata.service_record_id) {
return {
label: 'Otvori detalje servisa',
run() {
window.dispatchEvent(new CustomEvent('notification:open-entity', {
detail: {
entityType: 'service_record',
entityId: metadata.service_record_id,
vehicleId: metadata.vehicle_id || null,
section: metadata.section || meta.section,
},
}));
},
};
}
return {
label: `Otvori ${meta.category}`,
run() {
window.dispatchEvent(new CustomEvent('navbar:navigate', { detail: meta.section }));
},
};
}
function getNotificationTypeMeta(kind) {
if (kind.startsWith('work_order_pdf')) {
return {
category: 'PDF dokumenti',
section: 'work-orders',
recommendation: 'Možete odmah preuzeti generirani PDF dokument.',
};
}
if (kind.startsWith('work_order')) {
return {
category: 'Putni nalozi',
section: 'work-orders',
recommendation: 'Provjerite status naloga i povezano vozilo.',
};
}
if (kind === 'service_context') {
return {
category: 'Servisni kontekst',
section: 'service-records',
recommendation: 'Kontekst je postavljen i spreman za rad na servisnim zapisima.',
};
}
if (kind.startsWith('service_')) {
return {
category: 'Servisni zapisi',
section: 'service-records',
recommendation: 'Provjerite detalje servisa i priloge.',
};
}
return {
category: 'Sustav',
section: 'dashboard',
recommendation: 'Pregledajte dashboard za dodatni kontekst.',
};
}
function levelClass(level) {
if (level === 'critical') return 'bg-red-100 text-red-700';
if (level === 'warning') return 'bg-amber-100 text-amber-700';
return 'bg-indigo-100 text-indigo-700';
}
export default function NotificationDetailModal({ open, notification, onClose }) {
const kind = useMemo(() => detectNotificationKind(notification), [notification]);
const meta = useMemo(() => getNotificationTypeMeta(kind), [kind]);
const action = useMemo(() => getEntityAction(notification, meta), [notification, meta]);
const showPdfLink = Boolean(
notification?.metadata?.entity_type === 'work_order_pdf' &&
notification?.metadata?.stage === 'completed' &&
notification?.metadata?.work_order_id
);
if (!open || !notification) {
return null;
}
return (
<ModalShell
onClose={onClose}
overlayClassName="z-50 p-4"
contentClassName="flex items-center justify-center min-h-full"
panelClassName="max-h-[calc(100vh-2rem)] w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-y-auto"
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Detalji notifikacije</h3>
<p className="text-xs text-text-muted">{formatTimestamp(notification.created_at)}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
<div className="space-y-4 px-5 py-4">
<div className="flex items-center gap-2">
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${levelClass(notification.level)}`}>
{notification.level || 'info'}
</span>
<span className="rounded-full bg-canvas-base px-2.5 py-1 text-xs font-semibold text-text-muted">
{meta.category}
</span>
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<p className="text-sm font-semibold text-text-main">{notification.title || 'Obavijest'}</p>
<p className="mt-2 text-sm text-text-muted">{notification.message || '-'}</p>
{showPdfLink && (
<button
type="button"
onClick={() => action.run()}
className="mt-2 text-sm font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-700"
>
{action.label}
</button>
)}
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Preporuka</p>
<p className="mt-1 text-sm text-text-main">{meta.recommendation}</p>
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
<button
type="button"
onClick={() => {
action.run();
onClose?.();
}}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
>
{action.label}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
Zatvori
</button>
</div>
</div>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,143 @@
// src/components/tasks/TaskForm.jsx
// Forma za kreiranje novog zadatka (POST /api/tasks/tasks/)
// Prati TodoForm.jsx arhitektonski obrazac.
import { useState, useEffect } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { $isOffline } from '../../stores/networkStore.js';
import { $tasks, createTask, fetchTasks, getStatusLabel } from '../../stores/taskStore.js';
import { showToast } from '../../stores/toastStore.js';
const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
const INITIAL = {
title: '',
description: '',
status: 'aktivan',
};
export default function TaskForm() {
const isOffline = useStore($isOffline);
const [form, setForm] = useState(INITIAL);
const [submitting, setSubmitting] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
// Učitaj zadatke na mount ako su prazni
if ($tasks.get().length === 0 && !isOffline) {
fetchTasks();
}
}, []);
if (!isMounted) {
// SSR placeholder — iste dimenzije kao forma da se izbjegne layout shift
return (
<div className="h-64 rounded-2xl border border-border-hairline bg-canvas-elevated animate-pulse" />
);
}
const field = (name) => (e) => setForm((prev) => ({ ...prev, [name]: e.currentTarget.value }));
const handleSubmit = async (e) => {
e.preventDefault();
if (isOffline) {
showToast('Nije moguće kreirati zadatak u offline modu.', 'error');
return;
}
if (!form.title.trim()) {
showToast('Naslov zadatka je obavezan.', 'error');
return;
}
setSubmitting(true);
try {
await createTask({
title: form.title.trim(),
description: form.description.trim(),
status: form.status,
});
e.target.reset();
setForm(INITIAL);
} catch (err) {
if (err?.name !== 'AbortError') {
showToast(err?.message || 'Greška pri kreiranju zadatka.', 'error');
}
} finally {
setSubmitting(false);
}
};
const disabled = submitting || isOffline;
return (
<div
className={`bg-canvas-elevated border border-border-hairline p-6 rounded-2xl shadow-[0_8px_30px_rgba(0,0,0,0.15)] h-fit transition-all ${
isOffline ? 'opacity-40 pointer-events-none' : ''
}`}
>
<h3 className="text-xs font-mono tracking-widest text-text-main uppercase border-b border-border-hairline pb-2 mb-4">
// NOVI_ZADATAK
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
Naslov zadatka <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.title}
onInput={field('title')}
disabled={disabled}
placeholder="Npr. Pregled hydrauličkog sustava dizalice..."
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main placeholder-text-muted/40 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none transition-all disabled:opacity-50"
required
/>
</div>
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
Opis / Tehnička napomena
</label>
<textarea
value={form.description}
onInput={field('description')}
disabled={disabled}
rows={3}
placeholder="Detalji zadatka, lokacija, prioritet, šifre grešaka..."
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main placeholder-text-muted/40 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 resize-none focus:outline-none transition-all disabled:opacity-50"
/>
</div>
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted">
Status
</label>
<select
value={form.status}
onChange={field('status')}
disabled={disabled}
className="w-full px-3 py-2 text-sm bg-canvas-deep border border-border-hairline rounded-lg text-text-main focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none transition-all disabled:opacity-50"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{getStatusLabel(s)}
</option>
))}
</select>
</div>
<button
type="submit"
disabled={disabled}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white text-xs py-2.5 rounded-lg font-semibold transition-all duration-200"
>
{submitting ? 'Kreiranje...' : 'Kreiraj zadatak'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import type { ToastMessage } from '../../hooks/useToast';
const STYLES: Record<ToastMessage['type'], string> = {
success: 'border-emerald-300 bg-emerald-500/15 text-emerald-100',
error: 'border-red-300 bg-red-500/15 text-red-100',
warning: 'border-amber-300 bg-amber-500/15 text-amber-100',
info: 'border-sky-300 bg-sky-500/15 text-sky-100',
};
interface ToastItemProps {
toast: ToastMessage;
onClose: (id: string) => void;
}
export default function ToastItem({ toast, onClose }: ToastItemProps) {
return (
<article
className={`pointer-events-auto rounded-lg border px-4 py-3 shadow-lg backdrop-blur ${STYLES[toast.type]}`}
role="status"
aria-live="polite"
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm">{toast.message}</p>
<button
type="button"
className="text-xs opacity-80 hover:opacity-100"
onClick={() => onClose(toast.id)}
aria-label="Close toast"
>
</button>
</div>
</article>
);
}

View File

@@ -0,0 +1,28 @@
import { useEffect } from 'preact/hooks';
import ToastItem from './ToastItem';
import { removeToast, toastMessages } from '../../hooks/useToast';
export default function ToastProvider() {
const toasts = Array.isArray(toastMessages.value) ? toastMessages.value : [];
useEffect(() => {
const timers = toasts.map((toast) =>
window.setTimeout(() => removeToast(toast.id), toast.durationMs)
);
return () => {
timers.forEach((timer) => window.clearTimeout(timer));
};
}, [toasts]);
if (!toasts.length) {
return null;
}
return (
<section className="pointer-events-none fixed right-4 top-4 z-[80] flex w-full max-w-sm flex-col gap-2">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={removeToast} />
))}
</section>
);
}

View File

@@ -0,0 +1,82 @@
import { animated, useTransition } from '@react-spring/web';
import { useEffect, useState } from 'preact/hooks';
export default function AnimatedDataTable({
columns = [],
rows = [],
rowKey = (row) => row?.id,
renderRow,
loading = false,
loadingMessage = 'Učitavanje...',
emptyMessage = 'Nema podataka.',
tableClassName = 'min-w-full text-sm',
headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
bodyClassName = 'divide-y divide-border-hairline',
rowClassName = 'hover:bg-canvas-deep',
wrapperClassName = 'overflow-x-auto',
trail = 35,
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const transitions = useTransition(rows, {
keys: (row) => rowKey(row),
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
trail,
config: { tension: 230, friction: 26 },
});
const colSpan = Math.max(1, columns.length);
return (
<div className={wrapperClassName}>
<table className={tableClassName}>
<thead className={headClassName}>
<tr>
{columns.map((column) => (
<th key={column.key} className={column.className}>
{column.label}
</th>
))}
</tr>
</thead>
<tbody className={bodyClassName}>
{!mounted && (
<tr>
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
{loadingMessage}
</td>
</tr>
)}
{mounted && loading && (
<tr>
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
{loadingMessage}
</td>
</tr>
)}
{mounted && !loading && rows.length === 0 && (
<tr>
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
{emptyMessage}
</td>
</tr>
)}
{mounted && !loading && transitions((style, row) => (
<animated.tr
key={rowKey(row)}
style={style}
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
>
{renderRow(row)}
</animated.tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { animated, useSpring } from '@react-spring/web';
export default function AnimatedPage({ children, className = '' }) {
const pageStyle = useSpring({
from: { opacity: 0, transform: 'translate3d(0,10px,0)' },
to: { opacity: 1, transform: 'translate3d(0,0,0)' },
config: { tension: 210, friction: 24 },
});
return (
<animated.div style={pageStyle} className={className}>
{children}
</animated.div>
);
}

View File

@@ -0,0 +1,51 @@
import { useEffect } from 'preact/hooks';
import { createPortal } from 'preact/compat';
function joinClasses(...classes) {
return classes.filter(Boolean).join(' ');
}
export default function ModalShell({
children,
onClose,
overlayClassName = '',
contentClassName = 'flex min-h-full items-center justify-center',
panelClassName = '',
closeOnBackdrop = true,
}) {
useEffect(() => {
const body = document.body;
const count = parseInt(body.dataset.modalCount || '0', 10);
body.dataset.modalCount = count + 1;
body.classList.add('modal-open');
return () => {
const next = parseInt(body.dataset.modalCount || '0', 10) - 1;
body.dataset.modalCount = next;
if (next <= 0) {
body.classList.remove('modal-open');
delete body.dataset.modalCount;
}
};
}, []);
const handleOverlayClick = closeOnBackdrop
? (event) => {
event.stopPropagation();
onClose?.(event);
}
: undefined;
return createPortal(
<div
className={joinClasses('fixed inset-0', overlayClassName)}
onClick={handleOverlayClick}
>
<div className={contentClassName}>
<div className={panelClassName} onClick={(event) => event.stopPropagation()}>
{children}
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,20 @@
import { useStore } from '@nanostores/preact';
import { $isOffline } from '../../stores/networkStore';
export default function NetworkGuard() {
const isOffline = useStore($isOffline);
if (!isOffline) return null;
const isDev = import.meta.env.DEV;
return (
<div
className="pointer-events-none fixed top-0 left-0 right-0 z-20 p-2 text-[11px] font-mono tracking-wider text-amber-300 bg-amber-500/10 border-b border-amber-500/20 backdrop-blur-md text-center"
role="alert"
>
<span className="font-bold">STATUS_OFFLINE:</span>{' '}
{isDev
? 'Veza s Django API-jem (8001) je prekinuta. Podaci se čitaju iz lokalnog cachea.'
: 'Veza je prekinuta. Podaci se čitaju iz lokalnog cachea.'}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useState, useEffect } from 'preact/hooks';
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const imaDarkKlasu = document.documentElement.classList.contains('dark');
setIsDark(imaDarkKlasu);
}, []);
const toggleTheme = () => {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
setIsDark(false);
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
setIsDark(true);
}
};
return (
<button
onClick={toggleTheme}
type="button"
className="p-2.5 rounded-s-xl bg-canvas-elevated border-y border-s border-border-hairline text-text-muted hover:text-text-main transition-all duration-200 focus:outline-none cursor-pointer flex items-center justify-center"
aria-label="Prebaci temu"
>
{isDark ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M12 7a5 5 0 100 10 5 5 0 000-10z"></path>
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 118.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
)}
</button>
);
}

View File

@@ -0,0 +1,5 @@
import ToastContainer from '../ToastContainer';
export default function Toast() {
return <ToastContainer />;
}

View File

@@ -0,0 +1,151 @@
// src/components/ui/UserDisplay.jsx
//
// Prikazuje podatke o korisniku direktno iz $user nanostores atoma.
//
// ARHITEKTONSKA ODLUKA — zašto store, ne prop:
// • $user je reaktivan atom — svaka promjena (login, refresh profila)
// automatski re-renderira sve UserDisplay instance bez prop-drillinga
// • Komponenta se poziva na više mjesta (Topbar, Sidebar, Profil) —
// proslijeđivanje user objekta kao prop-a kroz sve te slojeve je anti-pattern
//
// USAGE:
// <UserDisplay field="ime_prezime" /> — "Ivo Horvat"
// <UserDisplay field="ime" /> — "Ivo"
// <UserDisplay field="prezime" /> — "Horvat"
// <UserDisplay field="email" /> — "ivo@example.com"
// <UserDisplay field="uloga" /> — "Serviser" | "Administrator" | "Korisnik"
// <UserDisplay field="ime_prezime" variant="badge" /> — avatar krug + ime
// <UserDisplay field="ime_prezime" variant="card" /> — puna kartica s ulogom
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import { $user, $authReady } from '../../stores/authStore.js';
// ─── Mapa razrješivača polja ────────────────────────────────────────────────
// Dodavanje novog polja = jedna nova linija ovdje.
const FIELD_RESOLVER = {
ime_prezime: (u) =>
[`${u.first_name ?? ''}`.trim(), `${u.last_name ?? ''}`.trim()]
.filter(Boolean)
.join(' ') || u.email || '—',
ime: (u) => u.first_name?.trim() || u.email || '—',
prezime: (u) => u.last_name?.trim() || '—',
email: (u) => u.email || '—',
uloga: (u) => {
if (u.is_staff) return 'Administrator';
if (u.is_serviser) return 'Serviser';
return 'Korisnik';
},
};
// Dohvati inicijale za avatar krug (max 2 slova)
function getInitials(user) {
if (!user) return '?';
const first = user.first_name?.trim()?.[0] ?? '';
const last = user.last_name?.trim()?.[0] ?? '';
if (first || last) return `${first}${last}`.toUpperCase();
return (user.email?.[0] ?? '?').toUpperCase();
}
// ─── Varijante prikaza ──────────────────────────────────────────────────────
function TextVariant({ value, className }) {
return (
<span className={className ?? 'text-sm font-medium text-text-main'}>
{value}
</span>
);
}
function BadgeVariant({ user, value }) {
// Kompaktni prikaz za navigacijske trake — krug s inicijalima + ime
return (
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-[11px] font-bold text-white select-none">
{getInitials(user)}
</span>
<span className="hidden text-sm font-medium text-text-main sm:inline">
{value}
</span>
</div>
);
}
function CardVariant({ user, value }) {
// Prošireni prikaz — avatar + ime + uloga
const role = FIELD_RESOLVER.uloga(user);
return (
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-bold text-white select-none">
{getInitials(user)}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-text-main">{value}</p>
<p className="text-[11px] text-text-muted">{role}</p>
</div>
</div>
);
}
// Skeleton za loading stanje — iste dimenzije kao sadržaj, bez layout shifta
function Skeleton({ variant }) {
if (variant === 'card') {
return (
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-canvas-deep animate-pulse" />
<div className="space-y-1.5">
<div className="h-3 w-24 rounded bg-canvas-deep animate-pulse" />
<div className="h-2.5 w-16 rounded bg-canvas-deep animate-pulse" />
</div>
</div>
);
}
if (variant === 'badge') {
return (
<div className="flex items-center gap-2">
<div className="h-7 w-7 rounded-full bg-canvas-deep animate-pulse" />
<div className="hidden h-3 w-20 rounded bg-canvas-deep animate-pulse sm:block" />
</div>
);
}
return <div className="h-3 w-20 rounded bg-canvas-deep animate-pulse" />;
}
// ─── Glavna komponenta ──────────────────────────────────────────────────────
export default function UserDisplay({
field = 'ime_prezime',
variant = 'text',
fallback = '—',
className,
}) {
const user = useStore($user);
const authReady = useStore($authReady);
// SSR guard — na serveru uvijek renderira skeleton kako bi hydration bio miran
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => { setHasMounted(true); }, []);
// Prije mount-a (SSR) i dok auth nije spreman — skeleton
if (!hasMounted || !authReady) {
return <Skeleton variant={variant} />;
}
// Auth spreman ali user nije učitan (nije prijavljen)
if (!user) {
return variant === 'text'
? <span className={className ?? 'text-sm text-text-muted'}>{fallback}</span>
: <Skeleton variant={variant} />;
}
const resolver = FIELD_RESOLVER[field];
if (!resolver) {
console.warn(`[UserDisplay] Nepoznato polje: "${field}". Dostupna polja: ${Object.keys(FIELD_RESOLVER).join(', ')}`);
return <span className="text-sm text-red-500">?{field}</span>;
}
const value = resolver(user);
if (variant === 'badge') return <BadgeVariant user={user} value={value} />;
if (variant === 'card') return <CardVariant user={user} value={value} />;
return <TextVariant value={value} className={className} />;
}

21
frontend/src/config.css Normal file
View File

@@ -0,0 +1,21 @@
:root {
--app-canvas-base: #ffffff;
--app-canvas-elevated: #f9fafb;
--app-canvas-deep: #f3f4f6;
--app-brand-primary: #5c6bc0;
--app-brand-accent: #5e6ad2;
--app-brand-accent-bright: #4d59c2;
--app-text-main: #1f2937;
--app-text-muted: #4b5563;
--app-border-hairline: rgba(0, 0, 0, 0.09);
}
.dark {
--app-canvas-base: #1f2329;
--app-canvas-elevated: #2a2f36;
--app-canvas-deep: #343b44;
--app-brand-primary: #6f7de0;
--app-text-main: #f4f6fb;
--app-text-muted: #b3bcc8;
--app-border-hairline: rgba(255, 255, 255, 0.14);
}

View File

@@ -0,0 +1,44 @@
import { signal } from '@preact/signals';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface ToastMessage {
id: string;
type: ToastType;
message: string;
durationMs: number;
}
export const toastMessages = signal<ToastMessage[]>([]);
function getToastMessages(): ToastMessage[] {
return Array.isArray(toastMessages.value) ? toastMessages.value : [];
}
function nextId(): string {
return `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
}
export function removeToast(id: string): void {
toastMessages.value = getToastMessages().filter((toast) => toast.id !== id);
}
export function pushToast(
message: string,
type: ToastType = 'info',
durationMs = 3000
): string {
const id = nextId();
toastMessages.value = [{ id, type, message, durationMs }, ...getToastMessages()].slice(0, 5);
return id;
}
export function useToast() {
return {
success: (message: string, durationMs?: number) => pushToast(message, 'success', durationMs),
error: (message: string, durationMs?: number) => pushToast(message, 'error', durationMs),
info: (message: string, durationMs?: number) => pushToast(message, 'info', durationMs),
warning: (message: string, durationMs?: number) => pushToast(message, 'warning', durationMs),
dismiss: removeToast,
};
}

View File

@@ -0,0 +1,105 @@
---
import { ClientRouter } from 'astro:transitions';
import '../config.css';
import '../styles/global.css';
import ThemeToggle from '../components/ui/ThemeToggle.jsx';
import ButtonDisplayCounter from '../components/atom/ButtonDisplayCounter.jsx';
import Toast from '../components/ui/Toast.jsx';
import ToastTrigger from '../components/ToastTrigger.jsx';
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
import Navbar from '../components/Navbar.jsx';
import ToastProvider from '../components/toast/ToastProvider';
interface Props {
title: string;
isAuthPage?: boolean;
minimalNav?: boolean;
}
const { title, isAuthPage = false, minimalNav = false } = Astro.props as Props;
const isProd = import.meta.env.PROD;
const isDev = import.meta.env.DEV;
---
<!DOCTYPE html>
<html lang="hr" class="h-full scroll-smooth">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/pwa-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4f46e5" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<ClientRouter />
<title>{title}</title>
<script is:inline>
function primijeniTemu() {
const lokalnaTema = localStorage.getItem('theme');
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
primijeniTemu();
document.addEventListener('astro:page-load', primijeniTemu);
</script>
</head>
<body class="relative min-h-screen bg-canvas-base text-text-main font-sans antialiased transition-colors duration-300">
<div class="absolute inset-0 bg-[linear-gradient(to_right,var(--color-text-main)_0.03_1px,transparent_1px),linear-gradient(to_bottom,var(--color-text-main)_0.03_1px,transparent_1px)] bg-[size:64px_64px] opacity-[0.4] dark:opacity-[0.02] pointer-events-none z-0"></div>
{!isAuthPage && <NetworkGuard client:only="preact" />}
<Toast client:load />
<ToastProvider client:load />
{!isAuthPage && isDev && <ToastTrigger client:load />}
{!isAuthPage && <Navbar client:load minimal={minimalNav} />}
<div id="main-content" class="relative z-10 flex flex-col min-h-screen max-w-6xl mx-auto px-6 md:px-8 py-6">
<header class="flex flex-row justify-between items-end pb-6 mb-8 border-b border-border-hairline">
<div class="flex flex-col">
<span class="text-[10px] font-mono tracking-widest text-text-muted opacity-60 uppercase">// CORE_INFRASTRUCTURE</span>
<h1 class="text-xl font-bold bg-gradient-to-b from-text-main to-text-main/80 bg-clip-text text-transparent tracking-tight">
Astro ERP Platforma
</h1>
</div>
</header>
<main class="flex-1 w-full" transition:animate="fade">
<slot />
</main>
<footer class="mt-20 pt-6 border-t border-border-hairline flex flex-row justify-between items-center text-[11px] font-mono text-text-muted opacity-50">
<span>SYSTEM_STATUS: ACTIVE</span>
<span>&copy; {new Date().getFullYear()} CORE_LOGISTICS.</span>
</footer>
</div>
<script is:inline define:vars={{ isProd }}>
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && window.isSecureContext) {
window.addEventListener('load', async function () {
if (isProd) {
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
console.error('Service worker registration failed:', error);
});
return;
}
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith('erp-shell-'))
.map((key) => caches.delete(key))
);
}
});
}
</script>
</body>
</html>

112
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,112 @@
export class ApiError extends Error {
status: number;
details?: unknown;
constructor(message: string, status: number, details?: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.details = details;
}
}
export interface FetchJsonOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
timeoutMs?: number;
}
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
function buildUrl(endpoint: string): string {
return new URL(endpoint.replace(/^\/+/, ''), API_BASE).toString();
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return { ...headers };
}
function createTimeoutSignal(signal: AbortSignal | null | undefined, timeoutMs: number): {
signal: AbortSignal;
clear: () => void;
} {
const timeoutController = new AbortController();
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
const clear = () => {
clearTimeout(timer);
};
if (signal) {
signal.addEventListener(
'abort',
() => {
clear();
timeoutController.abort();
},
{ once: true }
);
}
return { signal: timeoutController.signal, clear };
}
export async function fetchJson<T>(endpoint: string, options: FetchJsonOptions = {}): Promise<T> {
const {
body,
headers,
timeoutMs = 10000,
signal,
...rest
} = options;
const requestHeaders: Record<string, string> = {
Accept: 'application/json',
...normalizeHeaders(headers),
};
let requestBody: BodyInit | undefined;
if (body !== undefined && body !== null) {
requestHeaders['Content-Type'] = 'application/json';
requestBody = JSON.stringify(body);
}
const timeout = createTimeoutSignal(signal, timeoutMs);
let response: Response;
try {
response = await fetch(buildUrl(endpoint), {
...rest,
headers: requestHeaders,
body: requestBody,
signal: timeout.signal,
});
} finally {
timeout.clear();
}
if (!response.ok) {
let details: unknown = null;
try {
details = await response.json();
} catch {
details = await response.text().catch(() => null);
}
const message =
typeof details === 'object' && details && 'detail' in details
? String((details as { detail: unknown }).detail)
: `API request failed (${response.status})`;
throw new ApiError(message, response.status, details);
}
if (response.status === 204) {
return null as T;
}
return (await response.json()) as T;
}

53
frontend/src/lib/db.ts Normal file
View File

@@ -0,0 +1,53 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
const DB_NAME = 'erp-cache-db';
const DB_VERSION = 1;
const CACHE_STORE = 'api_cache';
interface CacheEntry<T = unknown> {
key: string;
payload: T;
updatedAt: string;
}
interface ERPDBSchema extends DBSchema {
api_cache: {
key: string;
value: CacheEntry;
};
}
let dbPromise: Promise<IDBPDatabase<ERPDBSchema>> | null = null;
function getDB(): Promise<IDBPDatabase<ERPDBSchema>> {
if (!dbPromise) {
dbPromise = openDB<ERPDBSchema>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(CACHE_STORE)) {
db.createObjectStore(CACHE_STORE, { keyPath: 'key' });
}
},
});
}
return dbPromise;
}
export async function setCachedData<T>(key: string, payload: T): Promise<void> {
const db = await getDB();
await db.put(CACHE_STORE, {
key,
payload,
updatedAt: new Date().toISOString(),
});
}
export async function getCachedData<T>(key: string): Promise<CacheEntry<T> | null> {
const db = await getDB();
const row = await db.get(CACHE_STORE, key);
return (row as CacheEntry<T> | undefined) ?? null;
}
export async function deleteCachedData(key: string): Promise<void> {
const db = await getDB();
await db.delete(CACHE_STORE, key);
}

View File

@@ -0,0 +1,22 @@
export function shortUuid(id, size = 8) {
if (id == null) return '';
const raw = String(id).trim();
if (!raw) return '';
const compact = raw.split('-')[0] || raw;
return compact.slice(0, size).toUpperCase();
}
export function formatEntityCode(prefix, id, size = 8) {
const short = shortUuid(id, size);
if (!short) return `${prefix}-`;
return `${prefix}-${short}`;
}
export function formatPurposeLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) return '';
if (normalized === 'defektaza') return 'Defektaža';
if (normalized === 'kontrola') return 'Kontrola';
if (normalized === 'redovni_pregled') return 'Redovni pregled';
return value || '';
}

View File

@@ -0,0 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
---
<Layout title="Klijenti">
<FleetDashboardShell client:load pageMode="clients" initialSection="clients" />
</Layout>

View File

@@ -0,0 +1,37 @@
---
import Layout from '../../layouts/Layout.astro';
import DataView from '../../components/data/DataView';
import { fetchJson } from '../../lib/api';
export const prerender = false;
const slugParts = Astro.params.slug?.split('/').filter(Boolean) ?? [];
const endpoint = slugParts.length ? `${slugParts.join('/')}/` : 'fleet/work-orders/';
const cacheKey = `data:${endpoint}`;
let initialData: unknown[] | null = null;
let initialError: string | null = null;
try {
// SSR fetch for first paint; client island revalidates and syncs IndexedDB after hydration.
const payload = await fetchJson<unknown[]>(endpoint, { method: 'GET', timeoutMs: 8000 });
initialData = Array.isArray(payload) ? payload : [];
} catch (error) {
initialError = error instanceof Error ? error.message : 'SSR data fetch failed.';
}
---
<Layout title={`Data | ${endpoint}`}>
<div class="space-y-4">
<p class="text-sm text-text-muted">
Endpoint: <code class="rounded bg-canvas-deep px-2 py-1 text-text-main">{endpoint}</code>
</p>
<DataView
client:load
endpoint={endpoint}
cacheKey={cacheKey}
initialData={initialData}
initialError={initialError}
/>
</div>
</Layout>

View File

@@ -0,0 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
---
<Layout title="ERP Dashboard">
<FleetDashboardShell client:load />
</Layout>

View File

@@ -0,0 +1,10 @@
---
import Layout from '../layouts/Layout.astro';
import LoginForm from '../components/auth/LoginForm.jsx';
---
<Layout title="Prijava" isAuthPage={true}>
<div class="flex min-h-[60vh] items-center justify-center py-10">
<LoginForm client:load />
</div>
</Layout>

View File

@@ -0,0 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
---
<Layout title="Putni nalozi">
<FleetDashboardShell client:load pageMode="work-orders" initialSection="work-orders" />
</Layout>

View File

@@ -0,0 +1,9 @@
---
export const prerender = false;
import Layout from '../../layouts/Layout.astro';
import WorkOrderInvoicesPdfPage from '../../components/dashboard/WorkOrderInvoicesPdfPage.jsx';
---
<Layout title="Izrada putnog naloga" minimalNav={false}>
<WorkOrderInvoicesPdfPage client:load />
</Layout>

View File

@@ -0,0 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
---
<Layout title="Servisni zapisi">
<FleetDashboardShell client:load pageMode="service-records" initialSection="service-records" />
</Layout>

View File

@@ -0,0 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
---
<Layout title="Servisni zapisi">
<FleetDashboardShell client:load pageMode="service-records" initialSection="service-records" />
</Layout>

View File

@@ -0,0 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
---
<Layout title="Vozila i dizalice">
<FleetDashboardShell client:load pageMode="vehicles" initialSection="cranes" />
</Layout>

View File

@@ -0,0 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
---
<Layout title="Putni nalozi">
<FleetDashboardShell client:load pageMode="work-orders" initialSection="work-orders" />
</Layout>

View File

@@ -0,0 +1,133 @@
// src/services/apiClient.js
/**
* 🌐 CENTRALIZIRANI API KLIJENT / MREŽNI POSREDNIK (API INTERCEPTOR WRAPPER)
* * * [Arhitektonska uloga]:
* Služi kao jedinstveni apstraktni sloj (Wrapper) oko nativne pregledničke `fetch` funkcije.
* Njegova je zadaća izolirati kompletnu logiku mrežne komunikacije, zaglavlja i serijalizacije
* iz Preact UI komponenti i NanoStores skladišta, osiguravajući stopostotni DRY princip.
* * * [Ključne tehničke funkcionalnosti]:
* 1. Dinamička konfiguracija okruženja: Automatski čita `PUBLIC_API_URL` preko Vite kompajlera.
* 2. Pametno upravljanje zaglavljima: Automatski presreće mrežne zahtjeve i injektira
* `Authorization: Bearer <token>` zaglavlje iz klijentovog memorijskog `authStore`-a.
* 3. Detekcija polimorfnih paketa: Prepoznaje razliku između običnog `application/json` unosa
* i `FormData` objekata (slike s terena), sprječavajući korupciju mrežnog paketa.
* 4. Robusni parser validacijskih grešaka: Izvlači i tekstualno formatira kompleksne DRF
* strukture grešaka (npr. pogreške po poljima ili nizove) kako bi Toast sustav stabilno radio.
*/
import { $accessToken, setToken } from '../stores/authStore';
const KLIJENT_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
const POSLUZITELJ_URL = 'http://backend:8000/api/'; // 💡 Promijeni 'backend' u točan naziv tvog Django servisa iz docker-compose.yml!
const BASE_URL = typeof window === 'undefined' ? POSLUZITELJ_URL : KLIJENT_URL;
let refreshPromise = null;
if (import.meta.env.DEV) {
console.log(`[API_CLIENT]: Pokrenut mod. Lokacija: ${typeof window === 'undefined' ? 'SERVER (Docker)' : 'CLIENT (Browser)'} -> Endpoint: ${BASE_URL}`);
}
async function request(endpoint, options = {}) {
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
const url = new URL(endpoint.replace(/^\/+/, ''), BASE_URL).toString();
const headers = { ...options.headers };
const isFormData = options.body && (
options.body instanceof FormData ||
typeof options.body.append === 'function'
);
if (options.body && !isFormData) {
headers['Content-Type'] = 'application/json';
}
// 🔑 PAMETNA AUTORIZACIJA: Ako je proslijeđen eksplicitni serverToken (iz Astro.cookies), koristi njega.
// U suprotnom, povuci iz NanoStores (klijent mod).
let token = options.serverToken || $accessToken.get();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
let body = options.body;
if (body && !isFormData && typeof body !== 'string') {
body = JSON.stringify(body);
}
const config = { ...options, headers, body };
try {
let response = await fetch(url, config);
// Presretač za tihi refresh (Radi samo na klijentu, jer server ne radi automatski tihi refresh)
if (response.status === 401 && token && !cleanEndpoint.includes('token/refresh/') && typeof window !== 'undefined') {
console.warn("Access token istekao. Pokrećem tihi refresh...");
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
if (!refreshPromise) {
refreshPromise = (async () => {
const refreshResponse = await fetch(`${BASE_URL}token/refresh/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: refreshToken }),
});
if (!refreshResponse.ok) {
throw new Error("Sesija istekla.");
}
const refreshData = await refreshResponse.json();
const newAccessToken = refreshData.access;
setToken(newAccessToken, refreshData.refresh || refreshToken);
return newAccessToken;
})();
}
try {
const newAccessToken = await refreshPromise;
config.headers['Authorization'] = `Bearer ${newAccessToken}`;
response = await fetch(url, config);
} catch (refreshError) {
setToken(null);
window.location.href = '/login?session=expired';
throw refreshError;
} finally {
refreshPromise = null;
}
}
}
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
let errorMessage = `API Greška [${response.status}]`;
if (response.status === 400 && typeof errData === 'object') {
// Ako je greška DRF validacijska, flatten-iraj je u string ili objekt
throw { message: Object.values(errData).flat().join(', '), details: errData, status: 400 };
}
if (errData.detail) errorMessage = errData.detail;
throw { message: String(errorMessage), status: response.status };
}
if (options.responseType === 'blob') return await response.blob();
if (response.status === 204 || config.method === 'DELETE') return { success: true };
return await response.json();
} catch (error) {
// 401 obrađuje tihi refresh interceptor iznad (ne logiramo — nije bug).
// 404 na ručno pozvanim provjerama (heartbeat, itd.) nije pogreška arhitekture.
const silenced = error?.status === 401 || error?.status === 404;
if (!silenced) {
console.error(`Mrežni problem na ${endpoint}:`, error);
}
throw error;
}
}
export const api = {
get: (endpoint, options) => request(endpoint, { ...options, method: 'GET' }),
post: (endpoint, body, options) => request(endpoint, { ...options, method: 'POST', body }),
put: (endpoint, body, options) => request(endpoint, { ...options, method: 'PUT', body }),
patch: (endpoint, body, options) => request(endpoint, { ...options, method: 'PATCH', body }),
delete: (endpoint, options) => request(endpoint, { ...options, method: 'DELETE' }),
};

View File

@@ -0,0 +1,116 @@
import { atom } from 'nanostores';
function getCookie(name) {
if (typeof window === 'undefined') return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
export const $accessToken = atom(null);
export const $user = atom(null);
export const $authReady = atom(false);
let authHydrated = false;
export function setToken(token, refreshToken = null) {
if (token) {
if (typeof window !== 'undefined') {
localStorage.setItem('access_token', token);
document.cookie = `access_token=${token}; path=/; max-age=86400; SameSite=Lax;`;
if (refreshToken) {
localStorage.setItem('refresh_token', refreshToken);
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=604800; SameSite=Lax;`;
}
}
$accessToken.set(token);
if (typeof window !== 'undefined' && $user.get()?.id) {
import('./notificationStore.js').then(({ connectNotifications }) => connectNotifications());
}
return;
}
if (typeof window !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('todo_cache');
document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
}
if (typeof window !== 'undefined') {
import('./notificationStore.js').then(({ disconnectNotifications, clearNotifications }) => {
disconnectNotifications();
clearNotifications();
});
}
$accessToken.set(null);
$user.set(null);
}
export function isAuthenticated() {
return !!$accessToken.get();
}
export function hydrateAuthFromStorage() {
if (typeof window === 'undefined') {
return;
}
if (authHydrated) {
return;
}
authHydrated = true;
const token = getCookie('access_token') || localStorage.getItem('access_token');
if (token) {
$accessToken.set(token);
}
$authReady.set(true);
}
export async function loadCurrentUser() {
const token = $accessToken.get();
if (!token) {
$user.set(null);
return null;
}
const { api } = await import('../services/apiClient.js');
const userData = await api.get('users/me/');
$user.set(userData);
return userData;
}
export async function validateToken() {
const token = $accessToken.get();
if (!token) return false;
try {
await loadCurrentUser();
const { fetchNotifications, connectNotifications } = await import('./notificationStore.js');
await fetchNotifications();
await connectNotifications();
return true;
} catch (err) {
if (err?.status === 401) {
setToken(null);
if (typeof window !== 'undefined') {
window.location.href = '/login?session=expired';
}
}
return false;
}
}
export async function loginWithCredentials(email, password) {
const { api } = await import('../services/apiClient.js');
const response = await api.post('token/', { email, password });
setToken(response.access, response.refresh);
await loadCurrentUser();
return response;
}
export function logout() {
setToken(null);
}

View File

@@ -0,0 +1,39 @@
// src/stores/clientStore.js
// CRUD store za CRM klijente — endpoint: /api/crm/clients/
import { atom } from 'nanostores';
import { api } from '../services/apiClient.js';
export const $clients = atom([]);
export const $clientsLoading = atom(false);
export const $clientsError = atom(null);
export async function fetchClients(signal) {
$clientsLoading.set(true);
$clientsError.set(null);
try {
const data = await api.get('crm/clients/', { signal });
$clients.set(Array.isArray(data) ? data : (data.results ?? []));
} catch (err) {
if (err?.name !== 'AbortError') $clientsError.set(err.message ?? 'Greška');
} finally {
$clientsLoading.set(false);
}
}
export async function createClient(payload) {
const data = await api.post('crm/clients/', payload);
$clients.set([...$clients.get(), data]);
return data;
}
export async function updateClient(id, payload) {
const data = await api.patch(`crm/clients/${id}/`, payload);
$clients.set($clients.get().map((c) => (c.id === id ? data : c)));
return data;
}
export async function deleteClient(id) {
await api.delete(`crm/clients/${id}/`);
$clients.set($clients.get().filter((c) => c.id !== id));
}

View File

@@ -0,0 +1,8 @@
import { atom } from 'nanostores';
// Pohranjujemo cijeli objekt kolekcije (uključujući račune)
export const $currentCollection = atom(null);
export function updateCollection(data) {
$currentCollection.set(data);
}

View File

@@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const $counter = atom(0);

View File

@@ -0,0 +1,57 @@
import { atom } from 'nanostores';
export const $craneSerialByVehicle = atom({});
function normalizeVehicleId(vehicleId) {
if (vehicleId == null) {
return '';
}
return String(vehicleId);
}
function normalizeSerial(serial) {
return String(serial || '').trim();
}
export function cacheCraneSerial(vehicleId, serial) {
const key = normalizeVehicleId(vehicleId);
const value = normalizeSerial(serial);
if (!key || !value) {
return;
}
$craneSerialByVehicle.set({
...$craneSerialByVehicle.get(),
[key]: value,
});
}
export function getCachedCraneSerial(vehicleId) {
const key = normalizeVehicleId(vehicleId);
if (!key) {
return '';
}
return normalizeSerial($craneSerialByVehicle.get()[key]);
}
export function cacheCraneSerialsFromVehicles(vehicles) {
if (!Array.isArray(vehicles) || vehicles.length === 0) {
return;
}
const current = $craneSerialByVehicle.get();
const next = { ...current };
let changed = false;
for (const vehicle of vehicles) {
const key = normalizeVehicleId(vehicle?.id);
const value = normalizeSerial(vehicle?.crane_serial_number);
if (!key || !value) {
continue;
}
if (next[key] !== value) {
next[key] = value;
changed = true;
}
}
if (changed) {
$craneSerialByVehicle.set(next);
}
}

View File

@@ -0,0 +1,830 @@
import { atom, computed } from 'nanostores';
import { api } from '../services/apiClient';
import { $accessToken } from './authStore';
import { showToast } from './toastStore';
const DB_NAME = 'erp-offline-db';
const DB_VERSION = 1;
const STORE_KV = 'kv';
const STORE_QUEUE = 'queue';
const STORE_SYNC_LOG = 'sync_log';
const CACHE_KEY = 'fleet_dashboard_cache';
const MAX_SYNC_LOG_ITEMS = 150;
export const $dashboardLoading = atom(false);
export const $dashboardError = atom(null);
export const $cranes = atom([]);
export const $vehicles = $cranes;
export const $workOrders = atom([]);
export const $serviceRecords = atom([]);
export const $offlineQueueCount = atom(0);
export const $syncLog = atom([]);
let dbPromise = null;
let syncListenerStarted = false;
let isSyncInProgress = false;
function isBrowser() {
return typeof window !== 'undefined';
}
function nowIso() {
return new Date().toISOString();
}
function createTempId(prefix) {
return `offline-${prefix}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
}
function saveBlobToFile(blob, filename) {
if (!isBrowser()) {
return;
}
const href = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = href;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(href);
}
function normalizeCraneList(items) {
if (!Array.isArray(items)) {
return [];
}
return items.filter((item) => {
if (!item || typeof item !== 'object') return false;
return !item.asset_type || item.asset_type === 'crane';
});
}
function toSerializable(value) {
return JSON.parse(JSON.stringify(value));
}
function openDB() {
if (!isBrowser()) {
return Promise.reject(new Error('IndexedDB is unavailable on server'));
}
if (dbPromise) {
return dbPromise;
}
dbPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_KV)) {
db.createObjectStore(STORE_KV, { keyPath: 'key' });
}
if (!db.objectStoreNames.contains(STORE_QUEUE)) {
db.createObjectStore(STORE_QUEUE, { keyPath: 'id', autoIncrement: true });
}
if (!db.objectStoreNames.contains(STORE_SYNC_LOG)) {
db.createObjectStore(STORE_SYNC_LOG, { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('IndexedDB open failed'));
});
return dbPromise;
}
function runTx(storeName, mode, action) {
return openDB().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
const result = action(store);
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed'));
tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
}));
}
function idbGetKV(key) {
return openDB().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(STORE_KV, 'readonly');
const store = tx.objectStore(STORE_KV);
const request = store.get(key);
request.onsuccess = () => resolve(request.result ? request.result.value : null);
request.onerror = () => reject(request.error || new Error('IndexedDB get failed'));
}));
}
function idbSetKV(key, value) {
return runTx(STORE_KV, 'readwrite', (store) => {
store.put({ key, value: toSerializable(value), updated_at: nowIso() });
});
}
function idbAddQueueItem(item) {
return openDB().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(STORE_QUEUE, 'readwrite');
const store = tx.objectStore(STORE_QUEUE);
const request = store.add(toSerializable(item));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('IndexedDB queue add failed'));
}));
}
function idbGetQueueItems() {
return openDB().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(STORE_QUEUE, 'readonly');
const store = tx.objectStore(STORE_QUEUE);
const request = store.getAll();
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
request.onerror = () => reject(request.error || new Error('IndexedDB queue read failed'));
}));
}
function idbDeleteQueueItem(id) {
return runTx(STORE_QUEUE, 'readwrite', (store) => {
store.delete(id);
});
}
function idbPutQueueItem(item) {
return runTx(STORE_QUEUE, 'readwrite', (store) => {
store.put(toSerializable(item));
});
}
function idbAddSyncLog(entry) {
return openDB().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(STORE_SYNC_LOG, 'readwrite');
const store = tx.objectStore(STORE_SYNC_LOG);
store.add(toSerializable(entry));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error('IndexedDB sync log add failed'));
}));
}
function idbGetSyncLog() {
return openDB().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(STORE_SYNC_LOG, 'readonly');
const store = tx.objectStore(STORE_SYNC_LOG);
const request = store.getAll();
request.onsuccess = () => {
const rows = Array.isArray(request.result) ? request.result : [];
rows.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
resolve(rows.slice(0, MAX_SYNC_LOG_ITEMS));
};
request.onerror = () => reject(request.error || new Error('IndexedDB sync log read failed'));
}));
}
async function trimSyncLogIfNeeded() {
const rows = await idbGetSyncLog();
if (rows.length <= MAX_SYNC_LOG_ITEMS) {
return;
}
const keepIds = new Set(rows.slice(0, MAX_SYNC_LOG_ITEMS).map((row) => row.id));
await runTx(STORE_SYNC_LOG, 'readwrite', (store) => {
const request = store.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return;
if (!keepIds.has(cursor.value.id)) {
cursor.delete();
}
cursor.continue();
};
});
}
async function refreshSyncLogStore() {
try {
$syncLog.set(await idbGetSyncLog());
} catch {
$syncLog.set([]);
}
}
async function updateQueueCount() {
try {
const queue = await idbGetQueueItems();
$offlineQueueCount.set(queue.length);
} catch {
$offlineQueueCount.set(0);
}
}
async function persistDashboardCache() {
await idbSetKV(CACHE_KEY, {
vehicles: $vehicles.get(),
workOrders: $workOrders.get(),
serviceRecords: $serviceRecords.get(),
cachedAt: nowIso(),
});
}
async function restoreDashboardCache() {
try {
const cached = await idbGetKV(CACHE_KEY);
if (!cached) return false;
$vehicles.set(Array.isArray(cached.vehicles) ? cached.vehicles : []);
$workOrders.set(Array.isArray(cached.workOrders) ? cached.workOrders : []);
$serviceRecords.set(Array.isArray(cached.serviceRecords) ? cached.serviceRecords : []);
return true;
} catch {
return false;
}
}
function replaceTempItem(entity, tempId, serverItem) {
if (entity === 'work_orders') {
$workOrders.set($workOrders.get().map((item) => (item.id === tempId ? serverItem : item)));
} else if (entity === 'service_records') {
$serviceRecords.set($serviceRecords.get().map((item) => (item.id === tempId ? serverItem : item)));
} else if (entity === 'vehicles') {
$vehicles.set($vehicles.get().map((item) => (item.id === tempId ? serverItem : item)));
}
}
function replaceWorkOrderItem(workOrderId, nextValue) {
$workOrders.set(
$workOrders.get().map((item) => (String(item.id) === String(workOrderId) ? nextValue : item))
);
}
function appendLocalDraft(entity, payload, tempId) {
const base = {
...payload,
id: tempId,
is_offline_draft: true,
created_at: payload.created_at || nowIso(),
};
if (entity === 'work_orders') {
$workOrders.set([base, ...$workOrders.get()]);
} else if (entity === 'service_records') {
$serviceRecords.set([base, ...$serviceRecords.get()]);
} else if (entity === 'vehicles') {
$vehicles.set([base, ...$vehicles.get()]);
}
}
function describeSyncAction(item) {
if (item.entity === 'work_orders') return 'Putni nalog';
if (item.entity === 'service_records') return 'Servisni zapis';
if (item.entity === 'vehicles' || item.entity === 'cranes') return 'Dizalica';
return 'Zapis';
}
async function logSync(item, status, detail = '') {
await idbAddSyncLog({
entity: item.entity,
endpoint: item.endpoint,
method: item.method,
status,
detail,
created_at: nowIso(),
});
await trimSyncLogIfNeeded();
await refreshSyncLogStore();
}
async function processOfflineQueue() {
if (!isBrowser() || isSyncInProgress || !navigator.onLine || !$accessToken.get()) {
return;
}
const queue = await idbGetQueueItems();
if (!queue.length) {
await updateQueueCount();
return;
}
isSyncInProgress = true;
for (const item of queue.sort((a, b) => a.id - b.id)) {
try {
let response = null;
if (item.method === 'POST') {
response = await api.post(item.endpoint, item.body);
} else if (item.method === 'PATCH') {
response = await api.patch(item.endpoint, item.body);
} else if (item.method === 'PUT') {
response = await api.put(item.endpoint, item.body);
}
if (response && item.tempId && item.entity) {
replaceTempItem(item.entity, item.tempId, response);
} else if (response && item.entity === 'work_orders' && item.targetId) {
replaceWorkOrderItem(item.targetId, response);
}
await idbDeleteQueueItem(item.id);
await logSync(item, 'success', `${describeSyncAction(item)} sinkroniziran.`);
} catch (error) {
if (error?.status === 401) {
await logSync(item, 'failed', 'Sinkronizacija zaustavljena: potrebna prijava.');
break;
}
const detail = error?.message || 'Nepoznata greška sinkronizacije.';
await logSync(item, 'failed', detail);
}
}
await persistDashboardCache();
await updateQueueCount();
isSyncInProgress = false;
}
function startOfflineSyncListeners() {
if (!isBrowser() || syncListenerStarted) return;
syncListenerStarted = true;
updateQueueCount();
refreshSyncLogStore();
window.addEventListener('online', async () => {
await processOfflineQueue();
await fetchFleetDashboardData({ silent: true });
});
}
async function createWithOfflineFallback(entity, endpoint, body) {
const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
if (canSendNow) {
try {
const response = await api.post(endpoint, body);
if (entity === 'service_records') {
try {
const { fetchNotifications } = await import('./notificationStore.js');
await fetchNotifications();
} catch (notificationError) {
console.warn('Neuspjelo osvježavanje notifikacija nakon kreiranja servisnog zapisa.', notificationError);
}
}
await logSync(
{ entity, endpoint, method: 'POST' },
'success',
`${describeSyncAction({ entity })} poslan odmah (online).`
);
return response;
} catch (error) {
if (error?.status && error.status !== 0 && error.status !== 503) {
await logSync(
{ entity, endpoint, method: 'POST' },
'failed',
`Online slanje nije uspjelo: ${error.message || 'greška'}.`
);
throw error;
}
}
}
const tempId = createTempId(entity);
appendLocalDraft(entity, body, tempId);
await idbAddQueueItem({
tempId,
entity,
endpoint,
method: 'POST',
body,
queued_at: nowIso(),
});
await updateQueueCount();
await persistDashboardCache();
await logSync(
{ entity, endpoint, method: 'POST' },
'queued',
`${describeSyncAction({ entity })} spremljen offline i čeka internet vezu.`
);
showToast('Podatak je spremljen offline i sinkronizirat će se kad se veza vrati.', 'warning');
return { id: tempId, ...body, is_offline_draft: true };
}
async function queueWorkOrderUpdate(workOrderId, body) {
const queue = await idbGetQueueItems();
const queuedCreate = queue.find(
(item) =>
item.entity === 'work_orders' &&
item.method === 'POST' &&
String(item.tempId) === String(workOrderId)
);
if (queuedCreate) {
queuedCreate.body = {
...queuedCreate.body,
...body,
};
await idbPutQueueItem(queuedCreate);
return;
}
await idbAddQueueItem({
entity: 'work_orders',
endpoint: `fleet/work-orders/${workOrderId}/`,
method: 'PATCH',
body,
targetId: workOrderId,
queued_at: nowIso(),
});
}
export const $workOrdersWithVehicle = computed(
[$workOrders, $vehicles],
(workOrders, vehicles) => {
const vehicleById = new Map(vehicles.map((vehicle) => [vehicle.id, vehicle]));
return workOrders.map((workOrder) => ({
...workOrder,
vehicleInfo: vehicleById.get(workOrder.vehicle) || null,
craneInfo: vehicleById.get(workOrder.crane || workOrder.vehicle) || null,
}));
}
);
export const $dashboardStats = computed(
[$workOrders, $serviceRecords],
(workOrders, serviceRecords) => {
const today = new Date().toISOString().slice(0, 10);
const openWorkOrders = workOrders.filter((workOrder) => workOrder.status !== 'closed').length;
const closedWorkOrders = workOrders.filter((workOrder) => workOrder.status === 'closed').length;
const servicesToday = serviceRecords.filter((record) => record.service_date === today).length;
const warnings = serviceRecords.filter((record) => {
if (record.next_service_due_at == null || record.mileage == null) return false;
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
}).length;
return {
openWorkOrders,
closedWorkOrders,
servicesToday,
warnings,
};
}
);
export async function ensureVehiclesCatalog() {
if (!$accessToken.get()) {
return $vehicles.get();
}
if ($vehicles.get().length > 0) {
return $vehicles.get();
}
try {
const vehicles = await api.get('fleet/cranes/');
$vehicles.set(normalizeCraneList(vehicles));
await persistDashboardCache();
return $vehicles.get();
} catch (error) {
if (error?.status === 401) {
$dashboardError.set('Sesija je istekla. Prijavite se ponovno.');
return $vehicles.get();
}
throw error;
}
}
export async function fetchFleetDashboardData(options = {}) {
const { silent = false } = options;
startOfflineSyncListeners();
if (!silent) $dashboardLoading.set(true);
$dashboardError.set(null);
const token = $accessToken.get();
if (!token) {
await restoreDashboardCache();
await updateQueueCount();
if (!silent) $dashboardLoading.set(false);
return;
}
try {
const [workOrders, serviceRecords, vehicles] = await Promise.all([
api.get('fleet/work-orders/'),
api.get('fleet/service-records/'),
api.get('fleet/cranes/'),
]);
$workOrders.set(Array.isArray(workOrders) ? workOrders : []);
$serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
$vehicles.set(normalizeCraneList(vehicles));
await persistDashboardCache();
await processOfflineQueue();
} catch (error) {
const cacheLoaded = await restoreDashboardCache();
if (error?.status === 401) {
if (!cacheLoaded) {
$dashboardError.set('Prijavite se za prikaz podataka.');
}
} else if (!cacheLoaded) {
$dashboardError.set('Neuspješno dohvaćanje dashboard podataka.');
showToast('Trenutno nema mreže. Prikazani su lokalno spremljeni podaci ako postoje.', 'warning');
}
} finally {
await updateQueueCount();
await refreshSyncLogStore();
if (!silent) $dashboardLoading.set(false);
}
}
export async function createWorkOrder(data) {
return createWithOfflineFallback('work_orders', 'fleet/work-orders/', data);
}
export async function updateWorkOrder(workOrderId, changes) {
const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId));
if (!existing) {
throw new Error('Work order nije pronađen u lokalnom stanju.');
}
const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
if (canSendNow) {
try {
const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes);
replaceWorkOrderItem(workOrderId, response);
await persistDashboardCache();
await logSync(
{ entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
'success',
'Ažuriranje naloga poslano odmah (online).'
);
return response;
} catch (error) {
if (error?.status && error.status !== 0 && error.status !== 503) {
await logSync(
{ entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
'failed',
`Online update nije uspio: ${error.message || 'greška'}.`
);
throw error;
}
}
}
const optimistic = {
...existing,
...changes,
is_offline_draft: true,
};
replaceWorkOrderItem(workOrderId, optimistic);
await queueWorkOrderUpdate(workOrderId, changes);
await updateQueueCount();
await persistDashboardCache();
await logSync(
{ entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
'queued',
'Ažuriranje naloga spremljeno offline.'
);
showToast('Ažuriranje naloga spremljeno offline i čeka sinkronizaciju.', 'warning');
return optimistic;
}
export async function createServiceRecord(data) {
return createWithOfflineFallback('service_records', 'fleet/service-records/', data);
}
export async function updateServiceRecord(serviceRecordId, changes) {
const response = await api.patch(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/`, changes);
$serviceRecords.set(
$serviceRecords.get().map((item) => (
String(item.id) === String(serviceRecordId) ? response : item
))
);
await persistDashboardCache();
showToast('Servisni zapis je uspješno ažuriran.', 'success');
return response;
}
export async function uploadServicePhoto(serviceRecordId, imageFile, description = '', signal = null) {
const formData = new FormData();
formData.append('service_record', serviceRecordId);
formData.append('image', imageFile);
if (description.trim()) {
formData.append('description', description.trim());
}
const options = signal ? { signal } : {};
const response = await api.post('fleet/service-photos/', formData, options);
showToast('Fotografija je uspješno učitana.', 'success');
return response;
}
export async function uploadServiceAttachment(serviceRecordId, file, description = '', signal = null) {
const formData = new FormData();
formData.append('service_record', serviceRecordId);
formData.append('file', file);
if (description.trim()) {
formData.append('description', description.trim());
}
const options = signal ? { signal } : {};
const response = await api.post('fleet/service-attachments/', formData, options);
showToast('Datoteka je uspješno učitana.', 'success');
return response;
}
export async function fetchServiceRecordFiles(serviceRecordId) {
if (!serviceRecordId) {
return { photos: [], attachments: [] };
}
const [photos, attachments] = await Promise.all([
api.get(`fleet/service-photos/?service_record_id=${encodeURIComponent(serviceRecordId)}`),
api.get(`fleet/service-attachments/?service_record_id=${encodeURIComponent(serviceRecordId)}`),
]);
const normalizedPhotos = (Array.isArray(photos) ? photos : []).map((photo) => {
if (!photo?.id) {
return photo;
}
const query = new URLSearchParams({
w: '1280',
q: '75',
fmt: 'webp',
});
return {
...photo,
optimized_url: `fleet/service-photos/${encodeURIComponent(photo.id)}/optimized/?${query.toString()}`,
};
});
return {
photos: normalizedPhotos,
attachments: Array.isArray(attachments) ? attachments : [],
};
}
export async function fetchWorkOrderImages(workOrderId, options = {}) {
if (!workOrderId) {
return [];
}
const width = Number(options.w) > 0 ? Number(options.w) : 1280;
const quality = Number(options.q) > 0 ? Number(options.q) : 75;
const format = (options.fmt || 'webp').toLowerCase();
const query = new URLSearchParams({
w: String(width),
q: String(quality),
fmt: format,
});
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/images/?${query.toString()}`);
return Array.isArray(payload?.images) ? payload.images : [];
}
export async function fetchWorkOrderInvoices(workOrderId) {
if (!workOrderId) {
return [];
}
const payload = await api.get(`fleet/work-order-invoices/?work_order_id=${encodeURIComponent(workOrderId)}`);
return Array.isArray(payload) ? payload : [];
}
export async function fetchWorkOrderById(workOrderId) {
if (!workOrderId) {
return null;
}
return await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/`);
}
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
if (!workOrderId) {
return { tasks: [] };
}
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
return payload && typeof payload === 'object' ? payload : { tasks: [] };
}
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
const formData = new FormData();
formData.append('work_order', workOrderId);
formData.append('naziv_racuna', String(data.naziv_racuna || '').trim());
if (data.lokacija != null) {
formData.append('lokacija', String(data.lokacija).trim());
}
if (data.datum) {
formData.append('datum', data.datum);
}
if (data.opis != null) {
formData.append('opis', String(data.opis).trim());
}
if (data.imageFile instanceof File) {
formData.append('image', data.imageFile);
}
const options = signal ? { signal } : {};
const response = await api.post('fleet/work-order-invoices/', formData, options);
showToast('Račun je uspješno spremljen.', 'success');
return response;
}
export async function downloadWorkOrderPdf(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
showToast('Kreiran je zahtjev za PDF putnog naloga.', 'info');
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order.pdf`);
showToast('PDF putnog naloga je preuzet.', 'success');
}
export async function downloadWorkOrderInvoicesPdf(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
showToast('Kreiran je zahtjev za PDF računa putnog naloga.', 'info');
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.pdf`);
showToast('PDF računa putnog naloga je preuzet.', 'success');
}
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.pdf`);
showToast('PDF servisnih zapisa putnog naloga je preuzet.', 'success');
}
export function openWorkOrderInvoicesPdfPage(workOrderId) {
if (!isBrowser() || !workOrderId) {
return;
}
const url = `/putni-nalozi/racuni?workOrderId=${encodeURIComponent(workOrderId)}`;
window.open(url, '_blank', 'noopener,noreferrer');
}
export async function fetchTeamMembers() {
const members = await api.get('users/team-members/');
return Array.isArray(members) ? members : [];
}
export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {}) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, payload);
if (options.toast !== false) {
showToast('Email za putni nalog je poslan.', 'success');
}
return response;
}
export async function downloadServiceRecordPdf(serviceRecordId) {
if (!serviceRecordId) {
throw new Error('Service record ID je obavezan.');
}
const blob = await api.get(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${serviceRecordId}.service-record.pdf`);
showToast('PDF servisnog zapisa je preuzet.', 'success');
}
export async function sendServiceRecordEmail(serviceRecordId, payload = {}, options = {}) {
if (!serviceRecordId) {
throw new Error('Service record ID je obavezan.');
}
const response = await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, payload);
if (options.toast !== false) {
showToast('Email za servisni zapis je poslan.', 'success');
}
return response;
}
export async function createVehicle(data) {
return createWithOfflineFallback('cranes', 'fleet/cranes/', data);
}
export async function fetchVehicleById(vehicleId) {
if (!vehicleId) {
return null;
}
return await api.get(`fleet/cranes/${encodeURIComponent(vehicleId)}/`);
}
export async function updateVehicle(vehicleId, changes) {
if (!vehicleId) {
throw new Error('ID dizalice je obavezan.');
}
const existing = $vehicles.get().find((item) => String(item.id) === String(vehicleId));
if (!existing) {
throw new Error('Dizalica nije pronađena u lokalnom stanju.');
}
const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
if (!canSendNow) {
throw new Error('Uređivanje podataka dizalice trenutno nije dostupno offline.');
}
const response = await api.patch(`fleet/cranes/${encodeURIComponent(vehicleId)}/`, changes);
$vehicles.set(
$vehicles.get().map((item) => (
String(item.id) === String(vehicleId) ? response : item
))
);
await persistDashboardCache();
return response;
}
export async function triggerOfflineSync() {
await processOfflineQueue();
}

View File

@@ -0,0 +1,44 @@
// src/stores/invoiceStore.js
import { atom } from 'nanostores';
import { api } from '../services/apiClient';
import { showToast } from './toastStore';
export const $invoices = atom([]);
export const $clients = atom([]);
export const $invoicesLoading = atom(false);
export async function fetchClients() {
try {
const data = await api.get('invoicing/clients/');
$clients.set(Array.isArray(data) ? data : (data?.results ?? []));
} catch {
// Klijenti su opcionalni — ne blokiramo UI ako nisu dostupni
}
}
export async function fetchInvoices() {
$invoicesLoading.set(true);
try {
const data = await api.get('invoicing/invoices/');
$invoices.set(Array.isArray(data) ? data : (data?.results ?? []));
} catch (error) {
if (error?.status !== 401) {
showToast('Greška pri dohvatu faktura.', 'error');
}
} finally {
$invoicesLoading.set(false);
}
}
export async function createInvoice(data) {
const response = await api.post('invoicing/invoices/', data);
$invoices.set([response, ...$invoices.get()]);
showToast(`Faktura ${response.invoice_number} je uspješno kreirana.`, 'success');
return response;
}
export async function createTransaction(data) {
const response = await api.post('invoicing/transaction/', data);
showToast('Plaćanje je zabilježeno.', 'success');
return response;
}

View File

@@ -0,0 +1,41 @@
import { atom } from 'nanostores';
export const $isOffline = atom(false);
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
// Heartbeat endpoint: OPTIONS /api/token/
// DRF odgovara bez 405 greške i dovoljno je za provjeru dostupnosti backenda.
const HEARTBEAT_URL = new URL('token/', API_BASE).toString();
async function provjeriStvarnuVezu() {
try {
const response = await fetch(HEARTBEAT_URL, {
method: 'OPTIONS',
cache: 'no-store',
});
// Svaki HTTP odgovor (200, 204, 401...) = server živ.
// Samo 5xx ili network error = offline.
if (response.status >= 500) {
throw new Error('Server nedostupan');
}
$isOffline.set(false);
} catch {
$isOffline.set(true);
}
}
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
provjeriStvarnuVezu();
});
window.addEventListener('offline', () => {
$isOffline.set(true);
});
provjeriStvarnuVezu();
setInterval(provjeriStvarnuVezu, 10000);
}

View File

@@ -0,0 +1,149 @@
import { atom, computed } from 'nanostores';
import Pusher from 'pusher-js';
import { api } from '../services/apiClient';
import { $accessToken, $user } from './authStore';
import { showToast } from './toastStore';
export const $notifications = atom([]);
export const $isRealtimeConnected = atom(false);
export const $unreadNotifications = computed(
$notifications,
(notifications) => notifications.filter((notification) => !notification.is_read).length
);
let pusherClient = null;
let userChannel = null;
function getPusherConfig() {
const key = import.meta.env.PUBLIC_PUSHER_KEY || __PUSHER_KEY__;
const cluster = import.meta.env.PUBLIC_PUSHER_CLUSTER || __PUSHER_CLUSTER__;
const apiUrl = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
const authEndpoint = new URL('fleet/pusher-auth/', apiUrl).toString();
if (!key || !cluster) {
return null;
}
return { key, cluster, authEndpoint };
}
export async function fetchNotifications() {
try {
const data = await api.get('fleet/notifications/');
$notifications.set(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Greška pri dohvaćanju notifikacija:', error);
}
}
export async function markNotificationAsRead(notificationId) {
try {
const updated = await api.post(`fleet/notifications/${notificationId}/mark-read/`);
$notifications.set(
$notifications.get().map((notification) =>
notification.id === notificationId ? { ...notification, ...updated } : notification
)
);
} catch (error) {
showToast('Neuspješno označavanje notifikacije kao pročitane.', 'error');
throw error;
}
}
export async function markAllNotificationsAsRead() {
try {
await api.post('fleet/notifications/mark-all-read/');
$notifications.set(
$notifications.get().map((notification) => ({
...notification,
is_read: true,
}))
);
} catch (error) {
showToast('Neuspješno označavanje svih notifikacija kao pročitanih.', 'error');
throw error;
}
}
function attachChannelHandlers(channel) {
channel.bind('vehicle.notification.created', (payload) => {
const next = {
id: payload.id,
title: payload.title,
message: payload.message,
level: payload.level || 'info',
is_read: payload.is_read ?? false,
is_sent: false,
created_at: payload.created_at || new Date().toISOString(),
metadata: payload.metadata || null,
};
const existing = $notifications.get();
if (existing.some((notification) => notification.id === next.id)) {
return;
}
$notifications.set([next, ...existing]);
showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
});
}
export async function connectNotifications() {
if (typeof window === 'undefined') {
return;
}
const user = $user.get();
const token = $accessToken.get();
const pusherConfig = getPusherConfig();
if (!user?.id || !token || !pusherConfig) {
return;
}
if (userChannel) {
return;
}
pusherClient = new Pusher(pusherConfig.key, {
cluster: pusherConfig.cluster,
channelAuthorization: {
endpoint: pusherConfig.authEndpoint,
transport: 'ajax',
headersProvider: () => {
const currentToken = $accessToken.get();
return currentToken ? { Authorization: `Bearer ${currentToken}` } : {};
},
},
});
const channelName = `private-user-${user.id}`;
userChannel = pusherClient.subscribe(channelName);
attachChannelHandlers(userChannel);
pusherClient.connection.bind('connected', () => {
$isRealtimeConnected.set(true);
});
pusherClient.connection.bind('disconnected', () => {
$isRealtimeConnected.set(false);
});
}
export function disconnectNotifications() {
if (userChannel && pusherClient) {
pusherClient.unsubscribe(userChannel.name);
}
userChannel = null;
if (pusherClient) {
pusherClient.disconnect();
}
pusherClient = null;
$isRealtimeConnected.set(false);
}
export function clearNotifications() {
$notifications.set([]);
}

View File

@@ -0,0 +1,20 @@
// src/stores/racunStore.js
// NAPOMENA: Stari endpoint 'todo/racuni/' je zamijenjen s 'invoicing/invoices/'
import { atom } from 'nanostores';
import { api } from '../services/apiClient';
import { showToast } from './toastStore';
export const $racuni = atom([]);
export const $isLeapLoading = atom(false);
export async function fetchRacuneZaKlijenta(clientId) {
if (!clientId) return;
try {
const data = await api.get(`invoicing/invoices/?client=${clientId}`);
$racuni.set(Array.isArray(data) ? data : (data?.results ?? []));
} catch (error) {
if (error?.status !== 401 && error?.status !== 404) {
showToast('Greška pri dohvatu faktura.', 'error');
}
}
}

View File

@@ -0,0 +1,85 @@
// src/stores/serviceContextStore.js
//
// Reaktivni store za "Servisni kontekst" — odabrani kupac + vozilo.
// Atomi su inicijalno null; pozovi hydrateServiceContext() unutar useEffect.
// ServiceContextSelector.jsx i FleetDashboardShell.jsx dijele isti izvor istine.
import { atom } from 'nanostores';
const LS_CLIENT = 'topbar_selected_client';
const LS_VEHICLE = 'topbar_selected_vehicle';
export const $selectedClientId = atom(null);
export const $selectedVehicleId = atom(null);
function emitServiceContextChanged(clientId, vehicleId) {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('service-context:changed', {
detail: {
clientId: clientId || null,
vehicleId: vehicleId || null,
},
}));
}
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
export function hydrateServiceContext() {
if (typeof window === 'undefined') return;
const client = localStorage.getItem(LS_CLIENT);
const vehicle = localStorage.getItem(LS_VEHICLE);
$selectedClientId.set(client || null);
$selectedVehicleId.set(vehicle || null);
emitServiceContextChanged(client || null, vehicle || null);
}
/** Postavi novog kupca i resetiraj odabrano vozilo. */
export function setSelectedClient(id) {
$selectedClientId.set(id || null);
$selectedVehicleId.set(null);
if (id) {
localStorage.setItem(LS_CLIENT, id);
} else {
localStorage.removeItem(LS_CLIENT);
}
localStorage.removeItem(LS_VEHICLE);
emitServiceContextChanged(id || null, null);
}
/** Postavi odabrano vozilo. */
export function setSelectedVehicle(id) {
$selectedVehicleId.set(id || null);
if (id) {
localStorage.setItem(LS_VEHICLE, id);
} else {
localStorage.removeItem(LS_VEHICLE);
}
emitServiceContextChanged($selectedClientId.get(), id || null);
}
/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
export function setServiceContext(clientId, vehicleId) {
$selectedClientId.set(clientId || null);
$selectedVehicleId.set(vehicleId || null);
if (clientId) {
localStorage.setItem(LS_CLIENT, clientId);
} else {
localStorage.removeItem(LS_CLIENT);
}
if (vehicleId) {
localStorage.setItem(LS_VEHICLE, vehicleId);
} else {
localStorage.removeItem(LS_VEHICLE);
}
emitServiceContextChanged(clientId || null, vehicleId || null);
}
/** Poništi cijeli kontekst. */
export function clearServiceContext() {
$selectedClientId.set(null);
$selectedVehicleId.set(null);
localStorage.removeItem(LS_CLIENT);
localStorage.removeItem(LS_VEHICLE);
emitServiceContextChanged(null, null);
}

View File

@@ -0,0 +1,63 @@
// src/stores/taskStore.js
import { atom } from 'nanostores';
import { api } from '../services/apiClient';
import { showToast } from './toastStore';
export const $tasks = atom([]);
export const $tasksLoading = atom(false);
const STATUS_LABELS = {
aktivan: 'Aktivan',
servis: 'U tijeku',
neaktivan: 'Otkazano',
};
export function getStatusLabel(status) {
return STATUS_LABELS[status] ?? status;
}
export function isTaskActive(task) {
const status = String(task?.status || '').toLowerCase();
return status === 'aktivan' || status === 'servis';
}
export async function fetchTasks() {
$tasksLoading.set(true);
try {
const data = await api.get('tasks/tasks/');
$tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
} catch (error) {
if (error?.status !== 401) {
showToast('Greška pri dohvatu zadataka.', 'error');
}
} finally {
$tasksLoading.set(false);
}
}
export async function createTask(data) {
const response = await api.post('tasks/tasks/', data);
$tasks.set([response, ...$tasks.get()]);
showToast('Zadatak je uspješno kreiran.', 'success');
return response;
}
export async function updateTaskStatus(taskId, status) {
const response = await api.patch(`tasks/tasks/${taskId}/`, { status });
$tasks.set($tasks.get().map((t) => (String(t.id) === String(taskId) ? response : t)));
showToast(`Status zadatka promijenjen u: ${getStatusLabel(status)}`, 'success');
return response;
}
export async function updateTask(taskId, changes) {
const response = await api.patch(`tasks/tasks/${taskId}/`, changes);
$tasks.set($tasks.get().map((t) => (String(t.id) === String(taskId) ? response : t)));
showToast('Task je uspješno ažuriran.', 'success');
return response;
}
export async function deleteTask(taskId) {
await api.delete(`tasks/tasks/${taskId}/`);
$tasks.set($tasks.get().filter((t) => String(t.id) !== String(taskId)));
showToast('Zadatak je deaktiviran.', 'success');
}

View File

@@ -0,0 +1,41 @@
// src/stores/toastStore.js
import { atom } from 'nanostores';
export const $toast = atom(null);
export const $toasts = atom([]);
function getToastList() {
const current = $toasts.get();
return Array.isArray(current) ? current : [];
}
export function showToast(message, type = 'error', timeout = 4500) {
const toast = { message, type, timeout, id: Date.now() + Math.random() };
$toast.set(toast);
$toasts.set([toast, ...getToastList()].slice(0, 5));
return toast.id;
}
export function clearToast(id = null) {
if (id == null) {
const items = getToastList();
if (items.length <= 1) {
$toast.set(null);
$toasts.set([]);
return;
}
const next = items.slice(1);
$toasts.set(next);
$toast.set(next[0] ?? null);
return;
}
const next = getToastList().filter((item) => item.id !== id);
$toasts.set(next);
$toast.set(next[0] ?? null);
}
export function clearAllToasts() {
$toast.set(null);
$toasts.set([]);
}

View File

@@ -0,0 +1,242 @@
// src/stores/todoStore.js
import { atom, computed } from 'nanostores';
import { api } from '../services/apiClient';
import { showToast } from './toastStore';
export const $todos = atom([]);
export const $collections = atom([]);
export const $collectionIdFilter = atom('');
export const $refreshSignal = atom(0);
/**
* 📥 Dohvaća TODO stavke I kolekcije s backenda
*/
export async function fetchTodos() {
try {
// Dohvaćamo oboje paralelno
const [todosData, collectionsData] = await Promise.all([
api.get('todo/todos/'),
api.get('todo/collections/')
]);
$todos.set(todosData);
$collections.set(collectionsData); // 💡 Puni store s kolekcijama
} catch (error) {
console.error('Greška pri dohvaćanju podataka:', error);
showToast('Neuspješno dohvaćanje podataka.', 'error');
}
}
/**
* Dodaje novu stavku (podržava FormData sa slikom)
*/
export async function addTodo(formData) {
try {
const newTodo = await api.post('todo/todos/', formData);
// 🚀 MEHATRONIČKA DIJAGNOSTIKA: Pogledajmo što točno Django isporučuje u konzolu
console.log("🚀 DJANGO_RESPONSE_RAW:", newTodo);
// Osiguravamo da radimo s čistim nizom
const currentTodos = Array.isArray($todos.get()) ? $todos.get() : [];
// Dodajemo na vrh niza i ažuriramo atom
$todos.set([newTodo, ...currentTodos]);
showToast('Zadatak uspješno dodan.', 'success');
} catch (error) {
console.error('Greška pri izradi:', error);
showToast('Greška prilikom izrade stavke.', 'error');
}
}
/**
* ❌ Pesimistično brisanje stavke s UI-ja nakon potvrde backenda
*/
export async function deleteTodo(id) {
try {
// 🔥 POPRAVLJENO: DRF ModelViewSet briše preko DELETE metode na 'todo/todos/[id]/'
await api.delete(`todo/todos/${id}/`);
// Tek ovdje vršimo mutaciju klijentskog stanja (Pesimistični UI update)
$todos.set($todos.get().filter(todo => todo.id !== id));
showToast('Stavka uspješno obrisana.', 'success');
} catch (error) {
console.error('Greška pri brisanju:', error);
showToast('Brisanje nije uspjelo na backendu.', 'error');
}
}
/**
* 🔄 Promjena statusa zadatka (Završeno / Aktivno)
*/
export async function toggleTodoStatus(id, currentStatus) {
try {
// 🔥 POPRAVLJENO: Putanja za ažuriranje u ruteru je 'todo/todos/[id]/'
const updatedTodo = await api.patch(`todo/todos/${id}/`, {
is_completed: !currentStatus
});
// Tek nakon uspješnog odgovora s backenda, ažuriramo klijentsko stanje s novim podacima
$todos.set(
$todos.get().map(todo => (todo.id === id ? updatedTodo : todo))
);
} catch (error) {
console.error('Greška pri ažuriranju statusa:', error);
showToast('Nije uspjelo ažuriranje statusa zadatka.', 'error');
}
}
/**
* 📝 Uređivanje postojećeg zadatka (podržava FormData sa slikama)
* Nakon uspješnog odgovora s backenda, ažuriramo objekt u klijentskoj memoriji
* što automatski i trenutno osvježava TodoList na Dashboardu bez F5 osvježavanja!
*/
export async function updateTodo(id, formData) {
try {
// DRF ModelViewSet prima izmjene na 'todo/todos/[id]/'
const updatedTodo = await api.patch(`todo/todos/${id}/`, formData);
// ⚡ REAKTIVNA SINKRONIZACIJA: Mapiramo kroz trenutno stanje u memoriji
$todos.set(
$todos.get().map(todo => (todo.id === id ? updatedTodo : todo))
);
showToast('✓ Servisni nalog uspješno ažuriran.', 'success');
return updatedTodo;
} catch (error) {
console.error('Greška pri uređivanju zadatka:', error);
showToast('Nije uspjelo spremanje izmjena na poslužitelj.', 'error');
throw error;
}
}
/**
* 💡 KREIRANJE IZVEDENOG STOREA
* $filteredTodos automatski sluša $todos i collectionId.
* Čim se $todos promijeni, ovaj se store automatski re-izračunava.
*/
export const $filteredTodos = computed(
[$todos, $collectionIdFilter],
(todos, collectionId) => {
// 1. Ako na Dashboardu uopće nije odabran filter (prikazuju se svi zadatci), vrati sve odmah!
if (!collectionId) return todos;
const targetId = parseInt(collectionId, 10);
return todos.filter(todo => {
// Ako zadatak uopće nema definiranu kolekciju, preskoči ga sigurno
if (!todo.collection && !todo.collection_id) return false;
// Scenarij A: Django je vratio ugniježđeni objekt (todo.collection.id)
if (todo.collection && typeof todo.collection === 'object') {
return todo.collection.id === targetId;
}
// Scenarij B: Django je vratio samo ravan broj ili string ID (todo.collection ili todo.collection_id)
const directId = todo.collection_id || todo.collection;
return parseInt(directId, 10) === targetId;
});
}
);
/**
* 🔄 Posebna funkcija za osvježavanje samo kolekcija
*/
export async function fetchCollections() {
try {
const data = await api.get('todo/collections/');
$collections.set(data);
} catch (error) {
showToast('Neuspješno osvježavanje kolekcija.', 'error');
}
}
export const triggerRefresh = async () => {
$refreshSignal.set($refreshSignal.get() + 1);
await fetchTodos();
};
/**
* ⚡ KLIJENTSKO SKUPNO ZATVARANJE NALOGA (Bulk Complete)
* Budući da backend nema namjenski bulk endpoint, filtriramo sve nezavršene zadatke
* unutar ove kolekcije na klijentu i asinkrono ih paralelno zatvaramo kroz postojeće PATCH rute.
*/
export async function bulkCompleteCollectionTodos(collectionId) {
try {
// 1. Pronalazimo sve zadatke koji pripadaju toj kolekciji i koji još nisu završeni
const nezavrseniTodos = $todos.get().filter(todo => {
const colId = todo.collection && typeof todo.collection === 'object'
? todo.collection.id
: (todo.collection_id || todo.collection);
return parseInt(colId, 10) === parseInt(collectionId, 10) && !todo.is_completed;
});
if (nezavrseniTodos.length === 0) {
showToast('Svi nalozi za ovaj stroj su već zatvoreni.', 'info');
return;
}
showToast(`Pokrećem zatvaranje ${nezavrseniTodos.length} naloga...`, 'success');
// 2. Kreiramo niz asinkronih PATCH zahtjeva (Paralelno izvršavanje unutar Docker mreže)
const patchZahtjevi = nezavrseniTodos.map(todo =>
api.patch(`todo/todos/${todo.id}/`, { is_completed: true })
);
// 3. Čekamo da se svi zahtjevi izvrše na PostgreSQL bazi
const azuriraniRezultati = await Promise.all(patchZahtjevi);
// 4. Sinkroniziramo globalni atom sa svježim podatcima koje je vratio DRF update
const trenutniTodos = $todos.get();
const novaMrezaZadataka = trenutniTodos.map(todo => {
const ponovniZapis = azuriraniRezultati.find(r => r.id === todo.id);
return ponovniZapis ? ponovniZapis : todo;
});
$todos.set(novaMrezaZadataka);
showToast('✓ Svi servisni nalozi uspješno sinkronizirani i zatvoreni.', 'success');
} catch (error) {
console.error('Greška pri klijentskom skupnom zatvaranju:', error);
showToast('Nije uspjelo masovno zatvaranje svih naloga stroja.', 'error');
throw error;
}
}
/**
* Primjenjuje predložak na odabranu kolekciju.
* @param {number} collectionId - ID kolekcije (stroj/flota)
* @param {string} templateKey - Ključ predloška (npr. 'redovni_servis_1050')
*/
export async function primijeniServisniPredlozak(collectionId, templateKey) {
try {
const response = await fetch(`/api/todo/collections/${collectionId}/primijeni-predlozak/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Ovdje ide tvoj auth header (JWT ili Session)
},
body: JSON.stringify({ template_key: templateKey })
});
if (!response.ok) {
throw new Error('Greška pri primjeni predloška.');
}
const data = await response.json();
// Ovdje možeš ažurirati lokalni store s novim zadacima
// $kolekcije.set([...$collections.get(), ...data.todos]);
$collections.set([...trenutno, ...data.todos]);
return data; // Vraća listu novokreiranih zadataka
} catch (error) {
console.error("Kritična greška u servisu:", error);
throw error;
}
}

View File

@@ -0,0 +1,8 @@
// src/stores/uiStore.js
import { atom } from 'nanostores';
// Pohranjujemo ID zadatka koji je trenutno otvoren. Ako je null, modal je zatvoren.
export const $activeTodoId = atom(null);
export const openTodoModal = (id) => $activeTodoId.set(id);
export const closeTodoModal = () => $activeTodoId.set(null);

View File

@@ -0,0 +1,61 @@
/* src/styles/global.css */
@import "tailwindcss";
/* 1. Reci Tailwindu v4 da skenira Flowbite komponente unutar node_modules */
@source "../../node_modules/flowbite/**/*.js";
/* 2. Učitaj Flowbite plugin koristeći v4 sintaksu */
@plugin "flowbite/plugin";
@theme {
--color-canvas-base: var(--app-canvas-base);
--color-canvas-elevated: var(--app-canvas-elevated);
--color-canvas-deep: var(--app-canvas-deep);
--color-brand-primary: var(--app-brand-primary);
--color-brand-accent: var(--app-brand-accent);
--color-brand-accent-bright: var(--app-brand-accent-bright);
--color-text-main: var(--app-text-main);
--color-text-muted: var(--app-text-muted);
--color-border-hairline: var(--app-border-hairline);
/* Registracija globalne animacije */
--animate-blob-float: blobFloat 10s ease-in-out infinite;
@keyframes blobFloat {
0%, 100% { transform: translateY(0px) scale(1) rotate(0deg); }
50% { transform: translateY(-20px) scale(1.02) rotate(1deg); }
}
}
/* 🌙 DARK MOD: Kada 'html' element dobije klasu .dark, varijable poprimaju Blade Runner estetiku */
@variant dark (&:where(.dark, .dark *));
/* ==============================================================================
🚀 DRY UTILITY KOMPONENTE (Tailwind v4 Sintaksa)
============================================================================== */
/* Stvaramo unificiranu bazu gumba koja radi kroz Astro i Preact */
@utility btn-erp {
@apply inline-flex items-center justify-center font-sans font-bold uppercase tracking-wider text-xs px-4 py-2.5 rounded-md shadow-sm transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
}
/* Primarni gumb (Popravljen s ispravnom v4 prozirnošću na hoveru) */
@utility btn-erp-primary {
/* ✅ POPRAVLJENO: bg-brand-primary/90 postavlja prozirnost izravno na boju tijekom hover stanja */
@apply btn-erp bg-brand-primary text-white hover:bg-brand-primary/90 active:scale-[0.98];
}
/* Opcionalni Outline gumb */
@utility btn-erp-outline {
@apply btn-erp border border-border-hairline text-text-muted hover:bg-canvas-deep;
}
/* Modal blur efekt — blurira main content, ali ne i Navbar */
#main-content {
transition: filter 0.2s ease;
}
body.modal-open #main-content {
filter: blur(4px);
}