This commit is contained in:
61
frontend/src/components/AuthWidget.jsx
Normal file
61
frontend/src/components/AuthWidget.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/Navbar.jsx
Normal file
100
frontend/src/components/Navbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/NotificationBell.jsx
Normal file
135
frontend/src/components/NotificationBell.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/ToastContainer.jsx
Normal file
50
frontend/src/components/ToastContainer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/ToastTrigger.jsx
Normal file
13
frontend/src/components/ToastTrigger.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/atom/ButtonDisplayCounter.jsx
Normal file
15
frontend/src/components/atom/ButtonDisplayCounter.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/auth/LoginForm.jsx
Normal file
86
frontend/src/components/auth/LoginForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/crm/ClientsSection.jsx
Normal file
171
frontend/src/components/crm/ClientsSection.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/dashboard/DashboardTopbar.jsx
Normal file
83
frontend/src/components/dashboard/DashboardTopbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1036
frontend/src/components/dashboard/FleetDashboardShell.jsx
Normal file
1036
frontend/src/components/dashboard/FleetDashboardShell.jsx
Normal file
File diff suppressed because it is too large
Load Diff
172
frontend/src/components/dashboard/ServiceContextSelector.jsx
Normal file
172
frontend/src/components/dashboard/ServiceContextSelector.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/dashboard/SyncLogPanel.jsx
Normal file
57
frontend/src/components/dashboard/SyncLogPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
frontend/src/components/dashboard/TaskCreateModal.jsx
Normal file
188
frontend/src/components/dashboard/TaskCreateModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
frontend/src/components/dashboard/TaskServiceRecordsModal.jsx
Normal file
199
frontend/src/components/dashboard/TaskServiceRecordsModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1115
frontend/src/components/dashboard/WorkOrderDetailModal.jsx
Normal file
1115
frontend/src/components/dashboard/WorkOrderDetailModal.jsx
Normal file
File diff suppressed because it is too large
Load Diff
152
frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
Normal file
152
frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
Normal file
258
frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
592
frontend/src/components/dashboard/WorkOrderModal.jsx
Normal file
592
frontend/src/components/dashboard/WorkOrderModal.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
frontend/src/components/data/DataView.tsx
Normal file
114
frontend/src/components/data/DataView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
frontend/src/components/fleet/ServicePhotoUpload.jsx
Normal file
247
frontend/src/components/fleet/ServicePhotoUpload.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/fleet/ServiceRecordCreateButton.jsx
Normal file
11
frontend/src/components/fleet/ServiceRecordCreateButton.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
537
frontend/src/components/fleet/ServiceRecordDetailModal.jsx
Normal file
537
frontend/src/components/fleet/ServiceRecordDetailModal.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
374
frontend/src/components/fleet/ServiceRecordForm.jsx
Normal file
374
frontend/src/components/fleet/ServiceRecordForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/invoicing/InvoiceForm.jsx
Normal file
258
frontend/src/components/invoicing/InvoiceForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
143
frontend/src/components/tasks/TaskForm.jsx
Normal file
143
frontend/src/components/tasks/TaskForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/toast/ToastItem.tsx
Normal file
35
frontend/src/components/toast/ToastItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/toast/ToastProvider.tsx
Normal file
28
frontend/src/components/toast/ToastProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/ui/AnimatedDataTable.jsx
Normal file
82
frontend/src/components/ui/AnimatedDataTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/ui/AnimatedPage.jsx
Normal file
15
frontend/src/components/ui/AnimatedPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ui/ModalShell.jsx
Normal file
51
frontend/src/components/ui/ModalShell.jsx
Normal 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
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/NetworkGuard.jsx
Normal file
20
frontend/src/components/ui/NetworkGuard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/ui/ThemeToggle.jsx
Normal file
41
frontend/src/components/ui/ThemeToggle.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/ui/Toast.jsx
Normal file
5
frontend/src/components/ui/Toast.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ToastContainer from '../ToastContainer';
|
||||
|
||||
export default function Toast() {
|
||||
return <ToastContainer />;
|
||||
}
|
||||
151
frontend/src/components/ui/UserDisplay.jsx
Normal file
151
frontend/src/components/ui/UserDisplay.jsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user