feat: React 19 SPA frontend (v3.0) — volledige moderne UI op /app
- 24 pagina's: dashboard, clients, engagements, tasks, calendar, email, projects, time, invoices, finance, racks (visuele rack-view), networking, diagrams, market intel, AI studio (providers+chat), notifications, search, settings (algemeen/users/backup/audit) - Vite + React 19 + Tailwind v4 + recharts + lucide, dark neon thema, NL - Build gedeployed naar public/spa, geserveerd door Express op /app
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Users, Briefcase, CheckCircle2, Calendar, Mail,
|
||||
FolderKanban, Clock, Receipt, Wallet, BarChart3,
|
||||
Server, Share2, Network, Radar, Sparkles, Settings,
|
||||
Search, Bell, LogOut, Menu, X, ExternalLink, ShieldCheck, PenTool, FileSearch
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../auth';
|
||||
import { api } from '../api';
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
label: 'CRM',
|
||||
items: [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ to: '/clients', icon: Users, label: 'Cliënten' },
|
||||
{ to: '/engagements', icon: Briefcase, label: 'Engagements' },
|
||||
{ to: '/tasks', icon: CheckCircle2, label: 'Taken' },
|
||||
{ to: '/calendar', icon: Calendar, label: 'Agenda' },
|
||||
{ to: '/email', icon: Mail, label: 'Email' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Delivery',
|
||||
items: [
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Projecten' },
|
||||
{ to: '/time', icon: Clock, label: 'Uren' },
|
||||
{ to: '/invoices', icon: Receipt, label: 'Facturen' },
|
||||
{ to: '/finance', icon: Wallet, label: 'Finance' },
|
||||
{ to: '/reports', icon: BarChart3, label: 'Rapporten' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Data & Infra',
|
||||
items: [
|
||||
{ to: '/racks', icon: Server, label: 'Racks' },
|
||||
{ to: '/networking', icon: Share2, label: 'Netwerk' },
|
||||
{ to: '/diagrams', icon: Network, label: 'Diagrammen' },
|
||||
{ to: '/market', icon: Radar, label: 'Market Intel' },
|
||||
{ to: '/ai', icon: Sparkles, label: 'AI Studio' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Platform',
|
||||
items: [
|
||||
{ to: '/settings', icon: Settings, label: 'Instellingen' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const LEGACY_LINKS = [
|
||||
{ href: '/quality', icon: ShieldCheck, label: 'Data Quality' },
|
||||
{ href: '/architecture/designer', icon: PenTool, label: 'Pro Designer' },
|
||||
{ href: '/assess', icon: FileSearch, label: 'Assessments' }
|
||||
];
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/notifications').then(d => setUnread(d.unread || 0)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const doSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
setQuery('');
|
||||
setMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sidebar = (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-5 py-5 flex items-center gap-3 border-b border-border-soft">
|
||||
<div className="w-9 h-9 rounded-lg bg-accent-soft flex items-center justify-center glow-accent">
|
||||
<Network className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-[15px] tracking-tight">Mek-Tech</div>
|
||||
<div className="text-[11px] text-muted">Consultancy Platform</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-5">
|
||||
{NAV.map(section => (
|
||||
<div key={section.label}>
|
||||
<div className="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted">
|
||||
{section.label}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent font-medium'
|
||||
: 'text-muted hover:text-text hover:bg-card'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="w-4 h-4 shrink-0" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<div className="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted">
|
||||
Klassiek
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{LEGACY_LINKS.map(item => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-muted hover:text-text hover:bg-card transition-colors"
|
||||
>
|
||||
<item.icon className="w-4 h-4 shrink-0" />
|
||||
{item.label}
|
||||
<ExternalLink className="w-3 h-3 ml-auto opacity-50" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-border-soft">
|
||||
<div className="flex items-center gap-2.5 px-2 py-1.5">
|
||||
<div className="w-8 h-8 rounded-full bg-accent-soft flex items-center justify-center text-accent text-xs font-bold uppercase">
|
||||
{(user?.username || '?').slice(0, 2)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium truncate">{user?.username}</div>
|
||||
<div className="text-[11px] text-muted">{user?.role}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
title="Uitloggen"
|
||||
className="p-1.5 rounded-md text-muted hover:text-red hover:bg-card transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:block w-60 shrink-0 bg-bg-soft border-r border-border-soft">
|
||||
{sidebar}
|
||||
</aside>
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileOpen(false)} />
|
||||
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-bg-soft border-r border-border z-50">
|
||||
{sidebar}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Topbar */}
|
||||
<header className="h-14 shrink-0 bg-bg-soft border-b border-border-soft flex items-center gap-3 px-4">
|
||||
<button
|
||||
className="lg:hidden p-2 rounded-md text-muted hover:text-text hover:bg-card"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
>
|
||||
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
<form onSubmit={doSearch} className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Zoeken in cliënten, taken, engagements..."
|
||||
className="w-full bg-card border border-border rounded-lg pl-9 pr-3 py-1.5 text-[13px] placeholder:text-muted focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Link
|
||||
to="/notifications"
|
||||
className="relative p-2 rounded-md text-muted hover:text-text hover:bg-card transition-colors"
|
||||
title="Notificaties"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute top-1 right-1 w-4 h-4 rounded-full bg-red text-[10px] font-bold flex items-center justify-center pulse-dot">
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
|
||||
// ---- Kleurmapping voor statussen ----
|
||||
export const STATUS_COLORS = {
|
||||
// clients
|
||||
lead: 'yellow', active: 'green', paused: 'muted', archived: 'muted',
|
||||
// engagements
|
||||
planned: 'blue', in_progress: 'accent', completed: 'green', on_hold: 'yellow',
|
||||
// tasks
|
||||
todo: 'blue', done: 'green', cancelled: 'muted',
|
||||
// facturen
|
||||
draft: 'muted', sent: 'blue', paid: 'green', overdue: 'red',
|
||||
// overig
|
||||
critical: 'red', high: 'yellow', medium: 'blue', low: 'muted',
|
||||
planning: 'blue', inactive: 'muted', standby: 'yellow', decommissioned: 'muted',
|
||||
new: 'yellow', contacted: 'blue', qualified: 'accent', converted: 'green', lost: 'muted'
|
||||
};
|
||||
|
||||
const BADGE_STYLES = {
|
||||
accent: 'bg-accent-soft text-accent',
|
||||
green: 'bg-green/15 text-green',
|
||||
yellow: 'bg-yellow/15 text-yellow',
|
||||
red: 'bg-red/15 text-red',
|
||||
blue: 'bg-blue/15 text-blue',
|
||||
purple: 'bg-purple/15 text-purple',
|
||||
pink: 'bg-pink/15 text-pink',
|
||||
muted: 'bg-muted/15 text-muted'
|
||||
};
|
||||
|
||||
export function Badge({ color = 'muted', children, className = '' }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${BADGE_STYLES[color] || BADGE_STYLES.muted} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, labels = {} }) {
|
||||
return <Badge color={STATUS_COLORS[status] || 'muted'}>{labels[status] || status}</Badge>;
|
||||
}
|
||||
|
||||
export function Card({ title, subtitle, actions, children, className = '', padding = true }) {
|
||||
return (
|
||||
<div className={`bg-card border border-border-soft rounded-xl ${className}`}>
|
||||
{(title || actions) && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-soft">
|
||||
<div>
|
||||
<h3 className="text-[14px] font-semibold">{title}</h3>
|
||||
{subtitle && <div className="text-[12px] text-muted mt-0.5">{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className={padding ? 'p-4' : ''}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCard({ icon: Icon, label, value, sub, color = 'accent', to }) {
|
||||
const colors = {
|
||||
accent: 'text-accent bg-accent-soft',
|
||||
green: 'text-green bg-green/15',
|
||||
yellow: 'text-yellow bg-yellow/15',
|
||||
red: 'text-red bg-red/15',
|
||||
blue: 'text-blue bg-blue/15',
|
||||
purple: 'text-purple bg-purple/15'
|
||||
};
|
||||
const inner = (
|
||||
<div className="bg-card border border-border-soft rounded-xl p-4 card-hover h-full">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-[12px] text-muted mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold tracking-tight">{value}</div>
|
||||
{sub && <div className="text-[12px] text-muted mt-1">{sub}</div>}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${colors[color] || colors.accent}`}>
|
||||
<Icon className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return to ? <a href={to} className="block">{inner}</a> : inner;
|
||||
}
|
||||
|
||||
const BTN_VARIANTS = {
|
||||
primary: 'bg-accent text-bg hover:bg-accent/90 font-semibold',
|
||||
secondary: 'bg-card border border-border text-text hover:bg-card-hover',
|
||||
danger: 'bg-red/15 text-red hover:bg-red/25',
|
||||
ghost: 'text-muted hover:text-text hover:bg-card'
|
||||
};
|
||||
|
||||
export function Btn({ variant = 'primary', size = 'md', loading, disabled, children, className = '', ...props }) {
|
||||
const sizes = { sm: 'px-2.5 py-1 text-[12px]', md: 'px-3.5 py-2 text-[13px]', lg: 'px-5 py-2.5 text-[14px]' };
|
||||
return (
|
||||
<button
|
||||
disabled={disabled || loading}
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${BTN_VARIANTS[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, error, children, className = '' }) {
|
||||
return (
|
||||
<label className={`block ${className}`}>
|
||||
{label && <span className="block text-[12px] text-muted mb-1">{label}</span>}
|
||||
{children}
|
||||
{error && <span className="block text-[12px] text-red mt-1">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const inputCls =
|
||||
'w-full bg-bg-soft border border-border rounded-lg px-3 py-2 text-[13px] placeholder:text-muted focus:outline-none focus:border-accent/60 transition-colors';
|
||||
|
||||
export function Modal({ open, onClose, title, children, wide }) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => e.key === 'Escape' && onClose?.();
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
|
||||
<div className={`relative bg-card border border-border rounded-xl w-full ${wide ? 'max-w-3xl' : 'max-w-lg'} max-h-[90vh] overflow-y-auto fade-in`}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-soft sticky top-0 bg-card z-10">
|
||||
<h2 className="text-[15px] font-semibold">{title}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md text-muted hover:text-text hover:bg-bg-soft">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ text = 'Laden...' }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-muted">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-[13px]">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, hint, action }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
{Icon && (
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-soft flex items-center justify-center mb-3">
|
||||
<Icon className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[14px] font-medium">{title}</div>
|
||||
{hint && <div className="text-[12px] text-muted mt-1 max-w-sm">{hint}</div>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBox({ error, onRetry }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="bg-red/10 border border-red/30 rounded-lg px-4 py-3 text-[13px] text-red flex items-center justify-between">
|
||||
<span>{String(error.message || error)}</span>
|
||||
{onRetry && (
|
||||
<button onClick={onRetry} className="underline hover:no-underline ml-3 shrink-0">
|
||||
Opnieuw
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data-tabel met uniforme styling
|
||||
export function Table({ columns, rows, keyFn, onRowClick, empty }) {
|
||||
if (!rows || rows.length === 0) {
|
||||
return empty || <div className="text-center text-muted text-[13px] py-8">Geen gegevens</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-4 px-4">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-border-soft">
|
||||
{columns.map((c, i) => (
|
||||
<th key={i} className={`pb-2 pr-4 font-medium text-[12px] ${c.className || ''}`}>{c.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={keyFn ? keyFn(row) : i}
|
||||
className={`border-b border-border-soft/50 table-row-hover ${onRowClick ? 'cursor-pointer' : ''}`}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((c, j) => (
|
||||
<td key={j} className={`py-2.5 pr-4 ${c.tdClassName || ''}`}>
|
||||
{c.render ? c.render(row) : row[c.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user