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:
mo
2026-07-21 21:42:25 +02:00
parent 9cb0ba7639
commit bd5b80b853
42 changed files with 10483 additions and 0 deletions
+222
View File
@@ -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>
);
}
+218
View File
@@ -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>
);
}