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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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