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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mek-Tech — Consultancy Platform</title>
<script type="module" crossorigin src="/app/assets/index-CQEBt6sl.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-A3FsIOhP.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mek-Tech — Consultancy Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2907
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "mek-tech-spa",
"private": true,
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.510.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"recharts": "^2.15.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.7",
"@vitejs/plugin-react": "^4.4.1",
"tailwindcss": "^4.1.7",
"vite": "^6.3.5"
}
}
+87
View File
@@ -0,0 +1,87 @@
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './auth';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Clients from './pages/Clients';
import ClientDetail from './pages/ClientDetail';
import Engagements from './pages/Engagements';
import EngagementDetail from './pages/EngagementDetail';
import Tasks from './pages/Tasks';
import Calendar from './pages/Calendar';
import Email from './pages/Email';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import Time from './pages/Time';
import Invoices from './pages/Invoices';
import InvoiceDetail from './pages/InvoiceDetail';
import Finance from './pages/Finance';
import Racks from './pages/Racks';
import RackDetail from './pages/RackDetail';
import Networking from './pages/Networking';
import Diagrams from './pages/Diagrams';
import Market from './pages/Market';
import Ai from './pages/Ai';
import Notifications from './pages/Notifications';
import Search from './pages/Search';
import Settings from './pages/Settings';
import Placeholder from './pages/Placeholder';
function Spinner() {
return (
<div className="h-full flex items-center justify-center">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
function RequireAuth({ children }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return children;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/engagements" element={<Engagements />} />
<Route path="/engagements/:id" element={<EngagementDetail />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/email" element={<Email />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
<Route path="/time" element={<Time />} />
<Route path="/invoices" element={<Invoices />} />
<Route path="/invoices/:id" element={<InvoiceDetail />} />
<Route path="/finance" element={<Finance />} />
<Route path="/racks" element={<Racks />} />
<Route path="/racks/:id" element={<RackDetail />} />
<Route path="/networking" element={<Networking />} />
<Route path="/diagrams" element={<Diagrams />} />
<Route path="/market" element={<Market />} />
<Route path="/ai" element={<Ai />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="/search" element={<Search />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Placeholder />} />
</Routes>
</Layout>
</RequireAuth>
}
/>
</Routes>
);
}
+72
View File
@@ -0,0 +1,72 @@
// Centrale API-client voor de Mek-Tech SPA.
// Alle calls gaan naar /api/spa (sessie-cookie via credentials: 'include').
const BASE = '/api/spa';
export class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
async function request(path, options = {}) {
const res = await fetch(BASE + path, {
credentials: 'include',
headers: options.body ? { 'Content-Type': 'application/json' } : undefined,
...options,
body: options.body ? JSON.stringify(options.body) : undefined
});
if (res.status === 401) {
// Niet (meer) ingelogd — gooi event zodat de app naar login kan
window.dispatchEvent(new Event('mek:unauthorized'));
throw new ApiError('Niet ingelogd', 401);
}
let data = null;
const text = await res.text();
if (text) {
try { data = JSON.parse(text); } catch { data = null; }
}
if (!res.ok) {
throw new ApiError((data && data.error) || `Fout ${res.status}`, res.status);
}
return data;
}
export const api = {
get: (path) => request(path),
post: (path, body) => request(path, { method: 'POST', body }),
put: (path, body) => request(path, { method: 'PUT', body }),
del: (path) => request(path, { method: 'DELETE' })
};
// Helpers voor querystrings
export function qs(params) {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(params || {})) {
if (v !== undefined && v !== null && v !== '') p.set(k, v);
}
const s = p.toString();
return s ? `?${s}` : '';
}
// Formatters (NL)
export const fmt = {
euro: (n) => new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' }).format(n || 0),
date: (d) => {
if (!d) return '-';
const dt = new Date(d);
if (isNaN(dt)) return d;
return dt.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' });
},
datetime: (d) => {
if (!d) return '-';
const dt = new Date(d);
if (isNaN(dt)) return d;
return dt.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' }) + ' ' + dt.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
},
hours: (h) => `${(h || 0).toLocaleString('nl-NL', { maximumFractionDigits: 1 })}u`
};
+44
View File
@@ -0,0 +1,44 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { api } from './api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/auth/me')
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
// Globale 401-handler: terug naar login
useEffect(() => {
const handler = () => setUser(null);
window.addEventListener('mek:unauthorized', handler);
return () => window.removeEventListener('mek:unauthorized', handler);
}, []);
const login = useCallback(async (username, password) => {
const u = await api.post('/auth/login', { username, password });
setUser(u);
return u;
}, []);
const logout = useCallback(async () => {
try { await api.post('/auth/logout'); } catch { /* negeren */ }
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
+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>
);
}
+75
View File
@@ -0,0 +1,75 @@
@import "tailwindcss";
@theme {
--color-bg: #0a0e17;
--color-bg-soft: #0f1420;
--color-card: #131a2a;
--color-card-hover: #182136;
--color-border: #1f2a44;
--color-border-soft: #1a2438;
--color-text: #e6edf7;
--color-muted: #8b9bb8;
--color-accent: #00d4ff;
--color-accent-soft: rgba(0, 212, 255, 0.12);
--color-green: #3fb950;
--color-yellow: #d29922;
--color-red: #f85149;
--color-purple: #bc8cff;
--color-pink: #f778ba;
--color-blue: #58a6ff;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
}
html, body, #root {
height: 100%;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Scrollbars */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--color-bg-soft); }
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: #2a3a5c; }
/* Subtiele glow voor accent-elementen */
.glow-accent {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.15);
}
/* Kaart hover */
.card-hover {
transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.card-hover:hover {
background-color: var(--color-card-hover);
box-shadow: 0 0 28px rgba(0, 212, 255, 0.08);
}
/* Tabel basis */
.table-row-hover:hover {
background-color: rgba(0, 212, 255, 0.04);
}
/* Animaties */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.pulse-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './auth';
import './index.css';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter basename="/app">
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState } from 'react';
import { Plus, Star, Send, Pencil, Trash2, Bot } from 'lucide-react';
import { api } from '../api';
import { Card, Badge, Btn, Field, inputCls, Modal, Spinner, ErrorBox } from '../components/ui';
const PROVIDER_TYPES = ['openrouter', 'openai', 'anthropic', 'deepseek', 'groq', 'ollama', 'azure', 'custom'];
const EMPTY_PROVIDER = {
name: '', provider_type: 'openrouter', api_key: '', base_url: '',
default_model: '', is_active: true, is_default: false
};
export default function Ai() {
// Providers
const [providers, setProviders] = useState(null);
const [provError, setProvError] = useState(null);
const [provModal, setProvModal] = useState(false);
const [editProv, setEditProv] = useState(null);
const [provForm, setProvForm] = useState(EMPTY_PROVIDER);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
// Chat
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [chatting, setChatting] = useState(false);
const [chatError, setChatError] = useState(null);
const [clients, setClients] = useState([]);
const [clientId, setClientId] = useState('');
const listRef = useRef(null);
const loadProviders = () => {
setProvError(null);
api.get('/ai/providers').then(setProviders).catch(setProvError);
};
useEffect(loadProviders, []);
useEffect(() => { api.get('/clients').then(setClients).catch(() => {}); }, []);
useEffect(() => {
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [messages, chatting]);
// ---- Provider modal ----
const openProvNew = () => {
setEditProv(null);
setProvForm(EMPTY_PROVIDER);
setSaveError(null);
setProvModal(true);
};
const openProvEdit = (p) => {
setEditProv(p);
setProvForm({
name: p.name || '', provider_type: p.provider_type || 'openrouter', api_key: '',
base_url: p.base_url || '', default_model: p.default_model || '',
is_active: !!p.is_active, is_default: !!p.is_default
});
setSaveError(null);
setProvModal(true);
};
const saveProv = async (e) => {
e.preventDefault();
setSaving(true);
setSaveError(null);
try {
await api.post('/ai/providers', { ...provForm, id: editProv ? editProv.id : undefined });
setProvModal(false);
loadProviders();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
const deleteProv = async (p) => {
if (!window.confirm(`Provider "${p.name}" verwijderen?`)) return;
try {
await api.del(`/ai/providers/${p.id}`);
loadProviders();
} catch (err) {
alert(err.message || String(err));
}
};
// ---- Chat ----
const sendMessage = async (e) => {
e?.preventDefault();
const msg = input.trim();
if (!msg || chatting) return;
setInput('');
setChatError(null);
setMessages(prev => [...prev, { role: 'user', content: msg }]);
setChatting(true);
try {
const res = await api.post('/ai/chat', { message: msg, client_id: clientId || undefined });
setMessages(prev => [...prev, { role: 'assistant', content: res.content, model: res.model }]);
} catch (err) {
setChatError(err);
} finally {
setChatting(false);
}
};
return (
<div className="space-y-5 fade-in">
<div>
<h1 className="text-xl font-bold tracking-tight">AI</h1>
<p className="text-[13px] text-muted mt-0.5">Providers beheren en chatten met AI</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 items-start">
{/* Providers */}
<Card
title="Providers"
subtitle="Geconfigureerde AI-providers"
actions={<Btn size="sm" onClick={openProvNew}><Plus className="w-3.5 h-3.5" /> Provider toevoegen</Btn>}
>
{provError ? <ErrorBox error={provError} onRetry={loadProviders} /> : !providers ? <Spinner /> : providers.length === 0 ? (
<div className="text-muted text-[13px] py-6 text-center">Nog geen providers geconfigureerd</div>
) : (
<div className="space-y-2">
{providers.map(p => (
<div key={p.id} className="flex items-center gap-3 bg-bg-soft border border-border-soft rounded-lg px-3 py-2.5">
<span className={`w-2 h-2 rounded-full shrink-0 ${p.is_active ? 'bg-green' : 'bg-muted/40'}`} title={p.is_active ? 'Actief' : 'Inactief'} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium truncate">{p.name}</span>
<Badge color="blue">{p.provider_type}</Badge>
{!!p.is_default && <Star className="w-3.5 h-3.5 text-yellow fill-yellow shrink-0" />}
</div>
<div className="text-[11px] text-muted mt-0.5 truncate">
{p.default_model || 'geen model'} {p.api_key ? `· ${p.api_key}` : ''}
</div>
</div>
<button onClick={() => openProvEdit(p)} className="p-1.5 rounded-md text-muted hover:text-text hover:bg-card shrink-0" title="Bewerken">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => deleteProv(p)} className="p-1.5 rounded-md text-muted hover:text-red hover:bg-red/10 shrink-0" title="Verwijderen">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</Card>
{/* AI Chat */}
<Card title="AI Chat" subtitle="Stel een vraag aan de actieve provider" padding={false} className="flex flex-col">
<div className="px-4 pt-3">
<select className={inputCls} value={clientId} onChange={e => setClientId(e.target.value)}>
<option value="">Geen cliënt-context</option>
{clients.map(c => <option key={c.id} value={c.id}>Koppel aan: {c.name}</option>)}
</select>
</div>
<div ref={listRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3 min-h-[320px] max-h-[480px]">
{messages.length === 0 && !chatting && (
<div className="h-full flex flex-col items-center justify-center text-muted py-12">
<Bot className="w-8 h-8 mb-2" />
<div className="text-[13px]">Start een gesprek met de AI</div>
</div>
)}
{messages.map((m, i) => (
m.role === 'user' ? (
<div key={i} className="flex justify-end">
<div className="max-w-[80%] bg-accent text-bg rounded-2xl rounded-br-sm px-3.5 py-2 text-[13px] whitespace-pre-wrap">
{m.content}
</div>
</div>
) : (
<div key={i} className="flex justify-start">
<div className="max-w-[80%]">
<div className="bg-card border border-border-soft rounded-2xl rounded-bl-sm px-3.5 py-2 text-[13px] whitespace-pre-wrap">
{m.content}
</div>
{m.model && <div className="text-[10px] text-muted mt-1 ml-1">{m.model}</div>}
</div>
</div>
)
))}
{chatting && (
<div className="flex justify-start">
<div className="bg-card border border-border-soft rounded-2xl rounded-bl-sm px-4 py-3 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-muted animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 rounded-full bg-muted animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 rounded-full bg-muted animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
)}
</div>
{chatError && <div className="px-4 pb-2"><ErrorBox error={chatError} /></div>}
<form onSubmit={sendMessage} className="flex items-center gap-2 p-3 border-t border-border-soft">
<input
className={inputCls}
placeholder="Typ je bericht..."
value={input}
onChange={e => setInput(e.target.value)}
disabled={chatting}
/>
<Btn type="submit" loading={chatting} disabled={!input.trim()}>
<Send className="w-4 h-4" /> Verstuur
</Btn>
</form>
</Card>
</div>
{/* Provider-modal */}
<Modal open={provModal} onClose={() => setProvModal(false)} title={editProv ? 'Provider bewerken' : 'Provider toevoegen'}>
<form onSubmit={saveProv} className="space-y-3">
{saveError && <ErrorBox error={saveError} />}
<Field label="Naam *">
<input className={inputCls} value={provForm.name} onChange={e => setProvForm({ ...provForm, name: e.target.value })} required />
</Field>
<Field label="Type">
<select className={inputCls} value={provForm.provider_type} onChange={e => setProvForm({ ...provForm, provider_type: e.target.value })}>
{PROVIDER_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="API-key">
<input
type="password"
className={inputCls}
value={provForm.api_key}
onChange={e => setProvForm({ ...provForm, api_key: e.target.value })}
placeholder={editProv ? 'Leeg laten om te behouden' : 'sk-...'}
/>
</Field>
<Field label="Base URL">
<input className={inputCls} value={provForm.base_url} onChange={e => setProvForm({ ...provForm, base_url: e.target.value })} placeholder="https://..." />
</Field>
<Field label="Standaard model">
<input className={inputCls} value={provForm.default_model} onChange={e => setProvForm({ ...provForm, default_model: e.target.value })} />
</Field>
<div className="flex items-center gap-5">
<label className="flex items-center gap-2 text-[13px] cursor-pointer">
<input type="checkbox" checked={provForm.is_active} onChange={e => setProvForm({ ...provForm, is_active: e.target.checked })} className="accent-[#00d4ff]" />
Actief
</label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer">
<input type="checkbox" checked={provForm.is_default} onChange={e => setProvForm({ ...provForm, is_default: e.target.checked })} className="accent-[#00d4ff]" />
Standaard provider
</label>
</div>
<div className="flex justify-end gap-2 pt-1">
<Btn variant="secondary" type="button" onClick={() => setProvModal(false)}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</form>
</Modal>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Plus, Trash2, Pencil, Clock, User } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Badge, Btn, Field, inputCls, Modal, Spinner, ErrorBox } from '../components/ui';
const DAY_NAMES = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'];
const TYPE_LABELS = {
meeting: 'Afspraak', call: 'Telefoongesprek', deadline: 'Deadline',
workshop: 'Workshop', review: 'Review', other: 'Overig'
};
const TYPE_COLORS = {
meeting: 'accent', call: 'blue', deadline: 'red',
workshop: 'purple', review: 'yellow', other: 'muted'
};
// ---- datum-helpers ----
function pad(n) { return String(n).padStart(2, '0'); }
function dateKey(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
function parseDT(v) {
if (!v) return null;
const d = new Date(String(v).replace(' ', 'T'));
return isNaN(d) ? null : d;
}
function toLocalInput(v) {
const d = parseDT(v);
if (!d) return '';
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// Bouw het maandraster: weken van maandag t/m zondag
function buildGrid(cursor) {
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
// maandag = 0 ... zondag = 6
const offset = (first.getDay() + 6) % 7;
const start = new Date(first);
start.setDate(first.getDate() - offset);
const days = [];
const d = new Date(start);
// altijd volledige weken; zorg dat de hele maand zichtbaar is
do {
days.push(new Date(d));
d.setDate(d.getDate() + 1);
} while (d.getMonth() === cursor.getMonth() || days.length % 7 !== 0);
return days;
}
const EMPTY_FORM = {
title: '', event_type: 'meeting', start_time: '', end_time: '',
all_day: false, client_id: '', color: '#58a6ff', description: ''
};
function EventForm({ form, setForm, clients }) {
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
return (
<div className="space-y-3">
<Field label="Titel *">
<input className={inputCls} value={form.title} onChange={e => set('title', e.target.value)} placeholder="Bijv. kickoff meeting" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Type">
<select className={inputCls} value={form.event_type} onChange={e => set('event_type', e.target.value)}>
{Object.entries(TYPE_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</Field>
<Field label="Cliënt (optioneel)">
<select className={inputCls} value={form.client_id} onChange={e => set('client_id', e.target.value)}>
<option value=""> Geen </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Start *">
<input type="datetime-local" className={inputCls} value={form.start_time} onChange={e => set('start_time', e.target.value)} />
</Field>
<Field label="Einde *">
<input type="datetime-local" className={inputCls} value={form.end_time} onChange={e => set('end_time', e.target.value)} />
</Field>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-[13px] cursor-pointer">
<input type="checkbox" checked={form.all_day} onChange={e => set('all_day', e.target.checked)} className="accent-[#00d4ff]" />
Hele dag
</label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer">
Kleur
<input type="color" value={form.color} onChange={e => set('color', e.target.value)} className="w-8 h-8 rounded cursor-pointer bg-transparent border border-border" />
</label>
</div>
<Field label="Beschrijving">
<textarea className={inputCls} rows={3} value={form.description} onChange={e => set('description', e.target.value)} />
</Field>
</div>
);
}
export default function Calendar() {
const [cursor, setCursor] = useState(() => { const d = new Date(); d.setDate(1); return d; });
const [events, setEvents] = useState(null);
const [clients, setClients] = useState([]);
const [error, setError] = useState(null);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [editId, setEditId] = useState(null);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState(null);
const [detail, setDetail] = useState(null);
const load = () => {
setError(null);
api.get('/calendar').then(setEvents).catch(setError);
};
useEffect(load, []);
useEffect(() => { api.get('/clients').then(setClients).catch(() => {}); }, []);
const days = useMemo(() => buildGrid(cursor), [cursor]);
const weeks = days.length / 7;
const todayKey = dateKey(new Date());
const eventsByDay = useMemo(() => {
const map = {};
for (const ev of events || []) {
const s = parseDT(ev.start_time);
const e = parseDT(ev.end_time) || s;
if (!s) continue;
const cur = new Date(s.getFullYear(), s.getMonth(), s.getDate());
const end = new Date(e.getFullYear(), e.getMonth(), e.getDate());
while (cur <= end) {
const k = dateKey(cur);
(map[k] = map[k] || []).push(ev);
cur.setDate(cur.getDate() + 1);
}
}
return map;
}, [events]);
const shiftMonth = (n) => {
const d = new Date(cursor);
d.setMonth(d.getMonth() + n);
setCursor(d);
};
const goToday = () => { const d = new Date(); d.setDate(1); setCursor(d); };
const openCreate = (day) => {
const k = dateKey(day);
setEditId(null);
setForm({ ...EMPTY_FORM, start_time: `${k}T09:00`, end_time: `${k}T10:00` });
setFormError(null);
setFormOpen(true);
};
const openEdit = (ev) => {
setDetail(null);
setEditId(ev.id);
setForm({
title: ev.title || '',
event_type: ev.event_type || 'meeting',
start_time: toLocalInput(ev.start_time),
end_time: toLocalInput(ev.end_time),
all_day: !!ev.all_day,
client_id: ev.client_id || '',
color: ev.color || '#58a6ff',
description: ev.description || ''
});
setFormError(null);
setFormOpen(true);
};
const save = async () => {
if (!form.title.trim()) return setFormError('Titel is verplicht');
if (!form.start_time || !form.end_time) return setFormError('Start- en eindtijd zijn verplicht');
setSaving(true);
setFormError(null);
try {
const body = { ...form, client_id: form.client_id || null };
if (editId) await api.put(`/calendar/${editId}`, body);
else await api.post('/calendar', body);
setFormOpen(false);
load();
} catch (e) {
setFormError(e.message);
} finally {
setSaving(false);
}
};
const remove = async (ev) => {
if (!window.confirm(`Event "${ev.title}" verwijderen?`)) return;
try {
await api.del(`/calendar/${ev.id}`);
setDetail(null);
load();
} catch (e) {
alert(e.message);
}
};
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!events) return <Spinner />;
const monthLabel = cursor.toLocaleDateString('nl-NL', { month: 'long', year: 'numeric' });
return (
<div className="space-y-4 fade-in">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold tracking-tight">Kalender</h1>
<p className="text-[13px] text-muted mt-0.5 capitalize">{monthLabel}</p>
</div>
<div className="flex items-center gap-2">
<Btn variant="secondary" size="sm" onClick={() => shiftMonth(-1)}><ChevronLeft className="w-4 h-4" /></Btn>
<Btn variant="secondary" size="sm" onClick={goToday}>Vandaag</Btn>
<Btn variant="secondary" size="sm" onClick={() => shiftMonth(1)}><ChevronRight className="w-4 h-4" /></Btn>
<Btn size="sm" onClick={() => openCreate(new Date())}><Plus className="w-3.5 h-3.5" /> Nieuw event</Btn>
</div>
</div>
<Card padding={false} className="overflow-hidden">
{/* dagnamen */}
<div className="grid grid-cols-7 border-b border-border-soft">
{DAY_NAMES.map(d => (
<div key={d} className="px-2 py-2 text-[11px] font-medium text-muted text-center uppercase tracking-wide">{d}</div>
))}
</div>
{/* dagen */}
<div className="grid grid-cols-7" style={{ gridTemplateRows: `repeat(${weeks}, minmax(96px, auto))` }}>
{days.map((day) => {
const k = dateKey(day);
const dayEvents = eventsByDay[k] || [];
const inMonth = day.getMonth() === cursor.getMonth();
const isToday = k === todayKey;
return (
<div
key={k}
onClick={() => openCreate(day)}
className={`border-b border-r border-border-soft/50 p-1.5 cursor-pointer hover:bg-card-hover transition-colors min-h-[96px] ${inMonth ? '' : 'bg-bg-soft/40'}`}
>
<div className="flex justify-end mb-1">
<span className={`text-[11px] w-6 h-6 flex items-center justify-center rounded-full ${
isToday ? 'ring-2 ring-accent text-accent font-bold' : inMonth ? 'text-text' : 'text-muted'
}`}>
{day.getDate()}
</span>
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 3).map(ev => (
<button
key={ev.id}
onClick={(e) => { e.stopPropagation(); setDetail(ev); }}
className="w-full flex items-center gap-1 px-1.5 py-0.5 rounded text-left text-[11px] leading-tight"
style={{ backgroundColor: `${ev.color || '#58a6ff'}33`, color: ev.color || '#58a6ff' }}
>
<span className="truncate flex-1">{ev.title}</span>
<span className="hidden xl:inline shrink-0">
<Badge color={TYPE_COLORS[ev.event_type] || 'muted'}>{TYPE_LABELS[ev.event_type] || ev.event_type}</Badge>
</span>
</button>
))}
{dayEvents.length > 3 && (
<div className="text-[10px] text-muted px-1.5">+{dayEvents.length - 3} meer</div>
)}
</div>
</div>
);
})}
</div>
</Card>
{/* legenda */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-[12px] text-muted">Legenda:</span>
{Object.entries(TYPE_LABELS).map(([k, l]) => (
<Badge key={k} color={TYPE_COLORS[k]}>{l}</Badge>
))}
</div>
{/* Nieuw / bewerk event */}
<Modal open={formOpen} onClose={() => setFormOpen(false)} title={editId ? 'Event bewerken' : 'Nieuw event'}>
<EventForm form={form} setForm={setForm} clients={clients} />
{formError && <div className="text-red text-[12px] mt-3">{formError}</div>}
<div className="flex justify-end gap-2 mt-4">
<Btn variant="secondary" onClick={() => setFormOpen(false)}>Annuleren</Btn>
<Btn onClick={save} loading={saving}>{editId ? 'Opslaan' : 'Toevoegen'}</Btn>
</div>
</Modal>
{/* Event-detail */}
<Modal open={!!detail} onClose={() => setDetail(null)} title={detail?.title || ''}>
{detail && (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge color={TYPE_COLORS[detail.event_type] || 'muted'}>{TYPE_LABELS[detail.event_type] || detail.event_type}</Badge>
{!!detail.all_day && <Badge color="blue">Hele dag</Badge>}
<span className="inline-flex items-center gap-1.5 text-[12px] text-muted">
<span className="w-3 h-3 rounded-full inline-block" style={{ backgroundColor: detail.color || '#58a6ff' }} />
{detail.color}
</span>
</div>
<div className="flex items-center gap-2 text-[13px] text-muted">
<Clock className="w-4 h-4 shrink-0" />
<span>{fmt.datetime(detail.start_time)} {fmt.datetime(detail.end_time)}</span>
</div>
{detail.client_name && (
<div className="flex items-center gap-2 text-[13px] text-muted">
<User className="w-4 h-4 shrink-0" />
<span>{detail.client_name}</span>
</div>
)}
{detail.description && (
<p className="text-[13px] whitespace-pre-wrap">{detail.description}</p>
)}
<div className="flex justify-between pt-2 border-t border-border-soft">
<Btn variant="danger" size="sm" onClick={() => remove(detail)}><Trash2 className="w-3.5 h-3.5" /> Verwijderen</Btn>
<Btn size="sm" onClick={() => openEdit(detail)}><Pencil className="w-3.5 h-3.5" /> Bewerken</Btn>
</div>
</div>
)}
</Modal>
</div>
);
}
+441
View File
@@ -0,0 +1,441 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft, Mail, Phone, User, Globe, Pencil, Trash2, X, AlertTriangle, Plus, Lightbulb
} from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table, StatusBadge, Badge } from '../components/ui';
import { EngagementFormModal, TYPE_LABELS, ENG_STATUS_LABELS } from './Engagements';
const STATUS_LABELS = { lead: 'Lead', active: 'Actief', paused: 'Gepauzeerd', archived: 'Gearchiveerd' };
const INV_STATUS_LABELS = { draft: 'Concept', sent: 'Verzonden', paid: 'Betaald', overdue: 'Verlopen', cancelled: 'Geannuleerd' };
const EMPTY_FORM = {
name: '', industry: '', website: '', contact_name: '', contact_email: '',
contact_phone: '', status: 'lead', source: '', notes: ''
};
// Zelfde formulier als in Clients.jsx (bewerk-modus)
function ClientFormModal({ open, onClose, client, onSaved, onDelete }) {
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setError(null);
setForm(client ? {
name: client.name || '',
industry: client.industry || '',
website: client.website || '',
contact_name: client.contact_name || '',
contact_email: client.contact_email || '',
contact_phone: client.contact_phone || '',
status: client.status || 'lead',
source: client.source || '',
notes: client.notes || ''
} : EMPTY_FORM);
}, [open, client]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.put(`/clients/${client.id}`, form);
onSaved();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Cliënt bewerken" wide>
<form onSubmit={submit} className="space-y-3">
<ErrorBox error={error} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Bedrijfsnaam *" className="md:col-span-2">
<input className={inputCls} value={form.name} onChange={set('name')} required autoFocus />
</Field>
<Field label="Branche">
<input className={inputCls} value={form.industry} onChange={set('industry')} />
</Field>
<Field label="Website">
<input className={inputCls} value={form.website} onChange={set('website')} placeholder="https://..." />
</Field>
<Field label="Contactpersoon">
<input className={inputCls} value={form.contact_name} onChange={set('contact_name')} />
</Field>
<Field label="E-mail">
<input type="email" className={inputCls} value={form.contact_email} onChange={set('contact_email')} />
</Field>
<Field label="Telefoon">
<input className={inputCls} value={form.contact_phone} onChange={set('contact_phone')} />
</Field>
<Field label="Status">
<select className={inputCls} value={form.status} onChange={set('status')}>
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Bron" className="md:col-span-2">
<input className={inputCls} value={form.source} onChange={set('source')} placeholder="Bijv. referral, website, netwerk" />
</Field>
<Field label="Notities" className="md:col-span-2">
<textarea className={inputCls} rows={3} value={form.notes} onChange={set('notes')} />
</Field>
</div>
<div className="flex items-center justify-between gap-2 pt-1">
<Btn type="button" variant="danger" onClick={onDelete}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
<div className="flex gap-2">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</div>
</form>
</Modal>
);
}
const ADVICE_COLORS = { critical: 'text-red', high: 'text-yellow', medium: 'text-blue', low: 'text-muted' };
export default function ClientDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [tab, setTab] = useState('overzicht');
const [editOpen, setEditOpen] = useState(false);
const [engModalOpen, setEngModalOpen] = useState(false);
const [noteText, setNoteText] = useState('');
const [noteSaving, setNoteSaving] = useState(false);
const [noteError, setNoteError] = useState(null);
const load = () => {
setError(null);
api.get(`/clients/${id}`).then(setData).catch(setError);
};
useEffect(load, [id]);
if (!data) {
return error ? <ErrorBox error={error} onRetry={load} /> : <Spinner />;
}
const { client, engagements, assessments, advice, diagrams, notes, timeEntries, invoices } = data;
const totalHours = timeEntries.reduce((s, t) => s + (t.hours || 0), 0);
const websiteHref = client.website
? (/^https?:\/\//i.test(client.website) ? client.website : `https://${client.website}`)
: null;
const dismissAdvice = async (adviceId) => {
try {
await api.post(`/clients/${id}/advice/${adviceId}/dismiss`);
load();
} catch (err) {
setError(err);
}
};
const addNote = async (e) => {
e.preventDefault();
if (!noteText.trim()) return;
setNoteSaving(true);
setNoteError(null);
try {
await api.post(`/clients/${id}/notes`, { content: noteText.trim() });
setNoteText('');
load();
} catch (err) {
setNoteError(err);
} finally {
setNoteSaving(false);
}
};
const removeNote = async (noteId) => {
if (!window.confirm('Deze notitie verwijderen?')) return;
try {
await api.del(`/clients/${id}/notes/${noteId}`);
load();
} catch (err) {
setError(err);
}
};
const removeClient = async () => {
if (!window.confirm(`Cliënt "${client.name}" verwijderen? Dit kan niet ongedaan worden gemaakt.`)) return;
try {
await api.del(`/clients/${client.id}`);
navigate('/clients');
} catch (err) {
setError(err);
setEditOpen(false);
}
};
const tabs = [
{ key: 'overzicht', label: 'Overzicht' },
{ key: 'engagements', label: `Engagements (${engagements.length})` },
{ key: 'notities', label: `Notities (${notes.length})` },
{ key: 'uren', label: `Uren (${timeEntries.length})` },
{ key: 'facturen', label: `Facturen (${invoices.length})` }
];
return (
<div className="space-y-4 fade-in">
{error && <ErrorBox error={error} onRetry={load} />}
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl font-bold tracking-tight">{client.name}</h1>
<StatusBadge status={client.status} labels={STATUS_LABELS} />
</div>
<div className="flex items-center gap-4 mt-1.5 text-[12px] text-muted flex-wrap">
{client.industry && <span>{client.industry}</span>}
{websiteHref && (
<a href={websiteHref} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-accent hover:underline">
<Globe className="w-3.5 h-3.5" /> {client.website}
</a>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-[12px] text-muted flex-wrap">
{client.contact_name && <span className="flex items-center gap-1"><User className="w-3.5 h-3.5" /> {client.contact_name}</span>}
{client.contact_email && <span className="flex items-center gap-1"><Mail className="w-3.5 h-3.5" /> {client.contact_email}</span>}
{client.contact_phone && <span className="flex items-center gap-1"><Phone className="w-3.5 h-3.5" /> {client.contact_phone}</span>}
</div>
</div>
<div className="flex gap-2 shrink-0">
<Link
to="/clients"
className="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] bg-card border border-border text-text hover:bg-card-hover transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" /> Terug
</Link>
<Btn variant="secondary" onClick={() => setEditOpen(true)}>
<Pencil className="w-3.5 h-3.5" /> Bewerken
</Btn>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-border-soft overflow-x-auto">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-3 py-2 text-[13px] whitespace-nowrap border-b-2 -mb-px transition-colors ${tab === t.key ? 'border-accent text-text font-medium' : 'border-transparent text-muted hover:text-text'}`}
>
{t.label}
</button>
))}
</div>
{/* Tab: Overzicht */}
{tab === 'overzicht' && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Card title="Actief advies" subtitle="Automatisch gegenereerd" className="xl:col-span-2">
{advice.length === 0 ? (
<div className="text-muted text-[13px] py-4 text-center flex flex-col items-center gap-2">
<Lightbulb className="w-5 h-5" /> Alles onder controle
</div>
) : (
<div className="space-y-2">
{advice.map((a) => (
<div key={a.id} className="flex items-start gap-2.5 px-2.5 py-2 rounded-lg bg-bg-soft">
<AlertTriangle className={`w-4 h-4 mt-0.5 shrink-0 ${ADVICE_COLORS[a.priority] || 'text-blue'}`} />
<div className="min-w-0 flex-1">
<div className="text-[12px]">{a.advice_text}</div>
<div className="text-[11px] text-muted mt-0.5">{fmt.datetime(a.created_at)}</div>
</div>
<button
onClick={() => dismissAdvice(a.id)}
className="p-1 rounded text-muted hover:text-text shrink-0"
title="Advies afronden"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</Card>
<Card title="Notities">
{client.notes
? <p className="text-[13px] whitespace-pre-wrap">{client.notes}</p>
: <div className="text-muted text-[13px] py-2">Geen notities</div>}
</Card>
<Card title="Diagrammen" actions={<Link to="/diagrams" className="text-[12px] text-accent hover:underline">Alles</Link>}>
{diagrams.length === 0 ? (
<div className="text-muted text-[13px] py-2">Nog geen diagrammen</div>
) : (
<div className="space-y-1.5">
{diagrams.map((d) => (
<Link key={d.id} to="/diagrams" className="flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
<div className="min-w-0">
<div className="text-[13px] font-medium truncate">{d.name}</div>
<div className="text-[11px] text-muted">{d.diagram_type}</div>
</div>
<span className="text-[12px] text-muted shrink-0">{fmt.date(d.updated_at)}</span>
</Link>
))}
</div>
)}
</Card>
<Card title={`Assessments (${assessments.length})`} className="xl:col-span-2">
{assessments.length === 0 ? (
<div className="text-muted text-[13px] py-2">Nog geen assessments</div>
) : (
<div className="space-y-1.5">
{assessments.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
<span className="text-[13px] font-medium">Assessment #{a.id}</span>
<span className="text-[12px] text-muted">{fmt.datetime(a.created_at)}</span>
</div>
))}
</div>
)}
</Card>
</div>
)}
{/* Tab: Engagements */}
{tab === 'engagements' && (
<Card
title="Engagements"
actions={<Btn size="sm" onClick={() => setEngModalOpen(true)}><Plus className="w-3.5 h-3.5" /> Nieuw engagement</Btn>}
>
<Table
columns={[
{
label: 'Titel',
render: (e) => (
<Link to={`/engagements/${e.id}`} className="font-medium text-text hover:text-accent transition-colors">
{e.title}
</Link>
)
},
{ label: 'Type', render: (e) => TYPE_LABELS[e.type] || e.type },
{ label: 'Status', render: (e) => <StatusBadge status={e.status} labels={ENG_STATUS_LABELS} /> },
{ label: 'Start', render: (e) => <span className="text-muted">{fmt.date(e.start_date)}</span> }
]}
rows={engagements}
keyFn={(e) => e.id}
empty={<div className="text-muted text-[13px] py-4 text-center">Nog geen engagements</div>}
/>
</Card>
)}
{/* Tab: Notities */}
{tab === 'notities' && (
<Card title="Notities">
<form onSubmit={addNote} className="mb-4 space-y-2">
{noteError && <ErrorBox error={noteError} />}
<div className="flex items-end gap-2">
<textarea
className={inputCls}
rows={2}
placeholder="Schrijf een notitie..."
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
/>
<Btn type="submit" loading={noteSaving} className="shrink-0">Toevoegen</Btn>
</div>
</form>
{notes.length === 0 ? (
<div className="text-muted text-[13px] py-4 text-center">Nog geen notities</div>
) : (
<div className="space-y-1.5">
{notes.map((n) => (
<div key={n.id} className="flex items-start justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
<div className="min-w-0">
<div className="text-[13px] whitespace-pre-wrap">{n.content}</div>
<div className="text-[11px] text-muted mt-0.5">{n.created_by} · {fmt.datetime(n.created_at)}</div>
</div>
<button
onClick={() => removeNote(n.id)}
className="p-1.5 rounded-md text-muted hover:text-red hover:bg-bg-soft shrink-0"
title="Verwijderen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</Card>
)}
{/* Tab: Uren */}
{tab === 'uren' && (
<Card title="Urenregistratie" subtitle={`Totaal: ${fmt.hours(totalHours)}`}>
<Table
columns={[
{ label: 'Datum', render: (t) => fmt.date(t.date) },
{ label: 'Gebruiker', render: (t) => t.username || <span className="text-muted">-</span> },
{ label: 'Uren', render: (t) => fmt.hours(t.hours) },
{ label: 'Omschrijving', render: (t) => t.description || <span className="text-muted">-</span> },
{ label: 'Facturabel', render: (t) => <Badge color={t.billable ? 'green' : 'muted'}>{t.billable ? 'Ja' : 'Nee'}</Badge> },
{ label: 'Tarief', render: (t) => <span className="text-muted">{fmt.euro(t.hourly_rate)}</span> }
]}
rows={timeEntries}
keyFn={(t) => t.id}
empty={<div className="text-muted text-[13px] py-4 text-center">Nog geen uren geregistreerd</div>}
/>
{timeEntries.length > 0 && (
<div className="text-right text-[13px] mt-3 pt-3 border-t border-border-soft">
Totaal: <span className="font-semibold">{fmt.hours(totalHours)}</span>
</div>
)}
</Card>
)}
{/* Tab: Facturen */}
{tab === 'facturen' && (
<Card title="Facturen">
<Table
columns={[
{
label: 'Nummer',
render: (i) => (
<Link to={`/invoices/${i.id}`} className="font-medium text-text hover:text-accent transition-colors">
{i.number}
</Link>
)
},
{ label: 'Datum', render: (i) => <span className="text-muted">{fmt.date(i.date)}</span> },
{ label: 'Totaal', render: (i) => fmt.euro(i.total) },
{ label: 'Status', render: (i) => <StatusBadge status={i.status} labels={INV_STATUS_LABELS} /> }
]}
rows={invoices}
keyFn={(i) => i.id}
empty={<div className="text-muted text-[13px] py-4 text-center">Nog geen facturen</div>}
/>
</Card>
)}
<ClientFormModal
open={editOpen}
client={client}
onClose={() => setEditOpen(false)}
onSaved={() => { setEditOpen(false); load(); }}
onDelete={removeClient}
/>
<EngagementFormModal
open={engModalOpen}
onClose={() => setEngModalOpen(false)}
clientId={client.id}
onSaved={() => { setEngModalOpen(false); load(); }}
/>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Search, Pencil, Trash2, Users } from 'lucide-react';
import { api, qs, fmt } from '../api';
import { Card, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table, StatusBadge, EmptyState } from '../components/ui';
const STATUS_LABELS = { lead: 'Lead', active: 'Actief', paused: 'Gepauzeerd', archived: 'Gearchiveerd' };
const FILTERS = [
{ key: '', label: 'Alle' },
{ key: 'lead', label: 'Leads' },
{ key: 'active', label: 'Actief' },
{ key: 'paused', label: 'Gepauzeerd' },
{ key: 'archived', label: 'Gearchiveerd' }
];
const EMPTY_FORM = {
name: '', industry: '', website: '', contact_name: '', contact_email: '',
contact_phone: '', status: 'lead', source: '', notes: ''
};
function ClientFormModal({ open, onClose, client, onSaved, onDelete }) {
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setError(null);
setForm(client ? {
name: client.name || '',
industry: client.industry || '',
website: client.website || '',
contact_name: client.contact_name || '',
contact_email: client.contact_email || '',
contact_phone: client.contact_phone || '',
status: client.status || 'lead',
source: client.source || '',
notes: client.notes || ''
} : EMPTY_FORM);
}, [open, client]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
if (client) await api.put(`/clients/${client.id}`, form);
else await api.post('/clients', form);
onSaved();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title={client ? 'Cliënt bewerken' : 'Nieuwe cliënt'} wide>
<form onSubmit={submit} className="space-y-3">
<ErrorBox error={error} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Bedrijfsnaam *" className="md:col-span-2">
<input className={inputCls} value={form.name} onChange={set('name')} required autoFocus />
</Field>
<Field label="Branche">
<input className={inputCls} value={form.industry} onChange={set('industry')} />
</Field>
<Field label="Website">
<input className={inputCls} value={form.website} onChange={set('website')} placeholder="https://..." />
</Field>
<Field label="Contactpersoon">
<input className={inputCls} value={form.contact_name} onChange={set('contact_name')} />
</Field>
<Field label="E-mail">
<input type="email" className={inputCls} value={form.contact_email} onChange={set('contact_email')} />
</Field>
<Field label="Telefoon">
<input className={inputCls} value={form.contact_phone} onChange={set('contact_phone')} />
</Field>
<Field label="Status">
<select className={inputCls} value={form.status} onChange={set('status')}>
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Bron" className="md:col-span-2">
<input className={inputCls} value={form.source} onChange={set('source')} placeholder="Bijv. referral, website, netwerk" />
</Field>
<Field label="Notities" className="md:col-span-2">
<textarea className={inputCls} rows={3} value={form.notes} onChange={set('notes')} />
</Field>
</div>
<div className="flex items-center justify-between gap-2 pt-1">
<div>
{client && onDelete && (
<Btn type="button" variant="danger" onClick={onDelete}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
)}
</div>
<div className="flex gap-2">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>{client ? 'Opslaan' : 'Aanmaken'}</Btn>
</div>
</div>
</form>
</Modal>
);
}
export default function Clients() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [status, setStatus] = useState('');
const [search, setSearch] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const [modal, setModal] = useState({ open: false, client: null });
const load = () => {
setError(null);
api.get('/clients' + qs({ status, search: activeSearch })).then(setData).catch(setError);
};
useEffect(load, [status, activeSearch]);
const submitSearch = (e) => {
e.preventDefault();
setActiveSearch(search.trim());
};
const closeModal = () => setModal({ open: false, client: null });
const remove = async () => {
const c = modal.client;
if (!c) return;
if (!window.confirm(`Cliënt "${c.name}" verwijderen? Dit kan niet ongedaan worden gemaakt.`)) return;
try {
await api.del(`/clients/${c.id}`);
closeModal();
load();
} catch (err) {
setError(err);
}
};
return (
<div className="space-y-4 fade-in">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-xl font-bold tracking-tight">Cliënten</h1>
<p className="text-[13px] text-muted mt-0.5">Beheer je klantrelaties</p>
</div>
<Btn onClick={() => setModal({ open: true, client: null })}>
<Plus className="w-4 h-4" /> Nieuwe cliënt
</Btn>
</div>
<div className="flex items-center gap-3 flex-wrap">
<form onSubmit={submitSearch} className="flex gap-2">
<input
className={inputCls + ' w-64'}
placeholder="Zoeken op naam, contact of branche..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Btn type="submit" variant="secondary"><Search className="w-4 h-4" /> Zoeken</Btn>
</form>
<div className="flex gap-1 flex-wrap">
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setStatus(f.key)}
className={`px-3 py-1.5 rounded-lg text-[12px] transition-colors ${status === f.key ? 'bg-accent-soft text-accent font-medium' : 'text-muted hover:text-text hover:bg-card'}`}
>
{f.label}
</button>
))}
</div>
</div>
{error && <ErrorBox error={error} onRetry={load} />}
{!data && !error && <Spinner />}
{data && (
<Card padding={false}>
<div className="p-4">
<Table
columns={[
{
label: 'Naam',
render: (c) => (
<Link to={`/clients/${c.id}`} className="font-medium text-text hover:text-accent transition-colors">
{c.name}
</Link>
)
},
{ label: 'Contactpersoon', render: (c) => c.contact_name || <span className="text-muted">-</span> },
{ label: 'E-mail', render: (c) => c.contact_email || <span className="text-muted">-</span> },
{ label: 'Branche', render: (c) => c.industry || <span className="text-muted">-</span> },
{ label: 'Status', render: (c) => <StatusBadge status={c.status} labels={STATUS_LABELS} /> },
{ label: 'Engagements', render: (c) => c.engagement_count },
{ label: 'Laatst bijgewerkt', render: (c) => <span className="text-muted">{fmt.date(c.updated_at)}</span> },
{
label: '',
render: (c) => (
<div className="flex items-center justify-end">
<button
onClick={() => setModal({ open: true, client: c })}
className="p-1.5 rounded-md text-muted hover:text-text hover:bg-bg-soft"
title="Bewerken"
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
)
}
]}
rows={data}
keyFn={(c) => c.id}
empty={<EmptyState icon={Users} title="Geen cliënten gevonden" hint="Pas de filters aan of voeg een nieuwe cliënt toe." />}
/>
</div>
</Card>
)}
<ClientFormModal
open={modal.open}
client={modal.client}
onClose={closeModal}
onSaved={() => { closeModal(); load(); }}
onDelete={remove}
/>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Users, Briefcase, CheckCircle2, Clock, Receipt, Wallet,
AlertTriangle, Lightbulb, Activity, ArrowRight
} from 'lucide-react';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid,
PieChart, Pie, Cell, Legend
} from 'recharts';
import { api, fmt } from '../api';
import { Card, KpiCard, Badge, StatusBadge, Spinner, ErrorBox, Table } from '../components/ui';
const PIE_COLORS = ['#00d4ff', '#3fb950', '#d29922', '#bc8cff', '#f778ba', '#58a6ff'];
const TYPE_LABELS = {
assessment: 'Assessment', architecture: 'Architectuur', implementation: 'Implementatie',
monitoring: 'Monitoring', consulting: 'Consulting', training: 'Training'
};
const STATUS_LABELS = {
planned: 'Gepland', in_progress: 'Lopend', completed: 'Afgerond', on_hold: 'On hold',
draft: 'Concept', sent: 'Verzonden', paid: 'Betaald', overdue: 'Verlopen', cancelled: 'Geannuleerd',
todo: 'Te doen', done: 'Klaar', lead: 'Lead', active: 'Actief', paused: 'Gepauzeerd', archived: 'Gearchiveerd'
};
const tooltipStyle = {
contentStyle: { backgroundColor: '#131a2a', border: '1px solid #1f2a44', borderRadius: 8, fontSize: 12 },
labelStyle: { color: '#8b9bb8' }
};
export default function Dashboard() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const load = () => {
setError(null);
api.get('/dashboard').then(setData).catch(setError);
};
useEffect(load, []);
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!data) return <Spinner />;
const { stats } = data;
const hoursChart = data.hoursByMonth.map(m => ({ ...m, maand: m.month.slice(5) }));
const revenueChart = data.revenueByMonth.map(m => ({ ...m, maand: m.month.slice(5) }));
const engTypeChart = data.engagementByType.map(e => ({ name: TYPE_LABELS[e.type] || e.type, value: e.cnt }));
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight">Dashboard</h1>
<p className="text-[13px] text-muted mt-0.5">Overzicht van je consultancy-praktijk</p>
</div>
<Link to="/clients">
<span className="text-[13px] text-accent hover:underline flex items-center gap-1">
Alle cliënten <ArrowRight className="w-3.5 h-3.5" />
</span>
</Link>
</div>
{/* KPI's */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<KpiCard icon={Users} label="Cliënten" value={stats.totalClients} sub={`${stats.activeClients} actief · ${stats.leads} leads`} color="accent" />
<KpiCard icon={Briefcase} label="Lopende engagements" value={stats.activeEngagements} color="blue" />
<KpiCard icon={CheckCircle2} label="Open taken" value={stats.totalTasks - stats.completedTasks} sub={stats.overdueTasks > 0 ? `${stats.overdueTasks} te laat` : undefined} color={stats.overdueTasks > 0 ? 'red' : 'green'} />
<KpiCard icon={Clock} label="Uren totaal" value={fmt.hours(stats.totalHours)} sub={`${fmt.hours(stats.billableHours)} facturabel`} color="purple" />
<KpiCard icon={Receipt} label="Openstaand" value={fmt.euro(stats.totalOutstanding)} color="yellow" />
<KpiCard icon={Wallet} label="Betaald" value={fmt.euro(stats.totalPaid)} color="green" />
</div>
{/* Grafieken */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<Card title="Uren per maand" subtitle="Totaal vs facturabel (6 mnd)" className="xl:col-span-2">
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={hoursChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2a44" vertical={false} />
<XAxis dataKey="maand" tick={{ fill: '#8b9bb8', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#8b9bb8', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip {...tooltipStyle} cursor={{ fill: 'rgba(0,212,255,0.06)' }} />
<Bar dataKey="hours" name="Totaal" fill="#58a6ff" radius={[4, 4, 0, 0]} />
<Bar dataKey="billable" name="Facturabel" fill="#00d4ff" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</Card>
<Card title="Engagements per type">
<div className="h-56">
{engTypeChart.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted text-[13px]">Nog geen engagements</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={engTypeChart} dataKey="value" nameKey="name" innerRadius={50} outerRadius={75} paddingAngle={3}>
{engTypeChart.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip {...tooltipStyle} />
<Legend iconSize={8} wrapperStyle={{ fontSize: 11, color: '#8b9bb8' }} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</Card>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{/* Lopende engagements */}
<Card
title="Recente engagements"
actions={<Link to="/engagements" className="text-[12px] text-accent hover:underline">Alles</Link>}
className="xl:col-span-1"
>
{data.recentEngagements.length === 0 ? (
<div className="text-muted text-[13px] py-4 text-center">Nog geen engagements</div>
) : (
<div className="space-y-2">
{data.recentEngagements.slice(0, 6).map(e => (
<Link key={e.id} to={`/engagements/${e.id}`} className="flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
<div className="min-w-0">
<div className="text-[13px] font-medium truncate">{e.title}</div>
<div className="text-[11px] text-muted">{e.client_name}</div>
</div>
<StatusBadge status={e.status} labels={STATUS_LABELS} />
</Link>
))}
</div>
)}
</Card>
{/* Aankomende taken */}
<Card
title="Taken op de plank"
actions={<Link to="/tasks" className="text-[12px] text-accent hover:underline">Alles</Link>}
className="xl:col-span-1"
>
{data.upcomingTasks.length === 0 ? (
<div className="text-muted text-[13px] py-4 text-center">Geen open taken</div>
) : (
<div className="space-y-2">
{data.upcomingTasks.slice(0, 6).map(t => (
<div key={t.id} className="flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
<div className="min-w-0">
<div className="text-[13px] font-medium truncate">{t.title}</div>
<div className="text-[11px] text-muted">{t.client_name} · {t.due_date ? fmt.date(t.due_date) : 'geen deadline'}</div>
</div>
<Badge color={STATUS_LABELS && t.priority === 'critical' ? 'red' : t.priority === 'high' ? 'yellow' : 'blue'}>{t.priority}</Badge>
</div>
))}
</div>
)}
</Card>
{/* Advies + activiteit */}
<Card title="Slim advies" subtitle="Automatisch gegenereerd" className="xl:col-span-1">
{data.activeAdvice.length === 0 ? (
<div className="text-muted text-[13px] py-4 text-center flex flex-col items-center gap-2">
<Lightbulb className="w-5 h-5" /> Alles onder controle
</div>
) : (
<div className="space-y-2">
{data.activeAdvice.slice(0, 5).map(a => (
<div key={a.id} className="flex items-start gap-2.5 px-2.5 py-2 rounded-lg hover:bg-card-hover">
<AlertTriangle className={`w-4 h-4 mt-0.5 shrink-0 ${a.priority === 'critical' ? 'text-red' : a.priority === 'high' ? 'text-yellow' : 'text-blue'}`} />
<div className="min-w-0">
<div className="text-[12px]">{a.advice_text}</div>
<div className="text-[11px] text-muted mt-0.5">{a.client_name}</div>
</div>
</div>
))}
</div>
)}
</Card>
</div>
{/* Facturen + activiteit */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Card title="Omzet per maand" subtitle="Betaald vs openstaand (6 mnd)">
<div className="h-52">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={revenueChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2a44" vertical={false} />
<XAxis dataKey="maand" tick={{ fill: '#8b9bb8', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#8b9bb8', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={v => `${v}`} />
<Tooltip {...tooltipStyle} formatter={(v) => fmt.euro(v)} cursor={{ fill: 'rgba(0,212,255,0.06)' }} />
<Bar dataKey="paid" name="Betaald" fill="#3fb950" radius={[4, 4, 0, 0]} />
<Bar dataKey="outstanding" name="Openstaand" fill="#d29922" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</Card>
<Card
title="Recente activiteit"
actions={<Link to="/settings?tab=audit" className="text-[12px] text-accent hover:underline flex items-center gap-1"><Activity className="w-3.5 h-3.5" /> Audit-log</Link>}
padding={false}
>
<div className="p-4">
<Table
columns={[
{ label: 'Wie', key: 'username' },
{ label: 'Actie', key: 'action' },
{ label: 'Wanneer', render: r => <span className="text-muted">{fmt.datetime(r.created_at)}</span> }
]}
rows={data.recentAudit}
keyFn={r => r.id}
empty={<div className="text-muted text-[13px] py-4 text-center">Nog geen activiteit</div>}
/>
</div>
</Card>
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react';
import { Trash2, Workflow, ArrowRight } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Badge, Btn, Modal, Spinner, ErrorBox, Table } from '../components/ui';
const TYPE_LABELS = {
data_architecture: 'Data-architectuur',
network_topology: 'Netwerk-topologie',
system_architecture: 'Systeem-architectuur',
application_flow: 'Applicatie-flow',
application_landscape: 'Applicatie-landscape'
};
const TYPE_COLORS = {
data_architecture: 'accent',
network_topology: 'green',
system_architecture: 'purple',
application_flow: 'blue',
application_landscape: 'pink'
};
function nodeLabel(n) {
return n.data?.label || n.label || n.name || n.id;
}
export default function Diagrams() {
const [diagrams, setDiagrams] = useState(null);
const [error, setError] = useState(null);
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState(null);
const [deleting, setDeleting] = useState(false);
const load = () => {
setError(null);
api.get('/diagrams').then(setDiagrams).catch(setError);
};
useEffect(load, []);
const openDetail = (row) => {
setDetail(null);
setDetailError(null);
setDetailLoading(true);
api.get(`/diagrams/${row.id}`)
.then(setDetail)
.catch(setDetailError)
.finally(() => setDetailLoading(false));
};
const closeDetail = () => {
setDetail(null);
setDetailError(null);
setDetailLoading(false);
};
const deleteDiagram = async () => {
if (!detail) return;
if (!window.confirm(`Diagram "${detail.name}" verwijderen?`)) return;
setDeleting(true);
setDetailError(null);
try {
await api.del(`/diagrams/${detail.id}`);
closeDetail();
load();
} catch (err) {
setDetailError(err);
} finally {
setDeleting(false);
}
};
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!diagrams) return <Spinner />;
const modalOpen = detailLoading || !!detail || !!detailError;
return (
<div className="space-y-5 fade-in">
<div>
<h1 className="text-xl font-bold tracking-tight">Diagrammen</h1>
<p className="text-[13px] text-muted mt-0.5">
Bewerken van diagrammen kan in de{' '}
<a href="/architecture" className="text-accent underline">klassieke editor</a>
</p>
</div>
<Card padding={false}>
<div className="p-4">
<Table
columns={[
{ label: 'Naam', render: d => <span className="font-medium flex items-center gap-2"><Workflow className="w-3.5 h-3.5 text-muted" />{d.name}</span> },
{ label: 'Cliënt', key: 'client_name' },
{ label: 'Type', render: d => <Badge color={TYPE_COLORS[d.diagram_type] || 'muted'}>{TYPE_LABELS[d.diagram_type] || d.diagram_type}</Badge> },
{ label: 'Beschrijving', render: d => <span className="text-muted block max-w-xs truncate">{d.description || '-'}</span> },
{ label: 'Bijgewerkt', render: d => <span className="text-muted">{fmt.datetime(d.updated_at)}</span> }
]}
rows={diagrams}
keyFn={d => d.id}
onRowClick={openDetail}
empty={<div className="text-center text-muted text-[13px] py-8">Nog geen diagrammen</div>}
/>
</div>
</Card>
<Modal open={modalOpen} onClose={closeDetail} title={detail ? detail.name : 'Diagram laden'} wide>
{detailLoading && <Spinner />}
{detailError && <ErrorBox error={detailError} />}
{detail && (
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap text-[12px] text-muted">
<Badge color={TYPE_COLORS[detail.diagram_type] || 'muted'}>{TYPE_LABELS[detail.diagram_type] || detail.diagram_type}</Badge>
<span>Bijgewerkt: {fmt.datetime(detail.updated_at)}</span>
</div>
{detail.description && <p className="text-[13px] text-muted">{detail.description}</p>}
<div>
<h3 className="text-[13px] font-semibold mb-2">Nodes ({detail.nodes.length})</h3>
{detail.nodes.length === 0 ? (
<div className="text-muted text-[13px]">Geen nodes</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{detail.nodes.map((n, i) => (
<div key={n.id || i} className="bg-bg-soft border border-border-soft rounded-lg px-3 py-2">
<div className="text-[13px] font-medium truncate">{nodeLabel(n)}</div>
{(n.type || n.data?.type) && (
<Badge color="blue" className="mt-1">{n.data?.type || n.type}</Badge>
)}
</div>
))}
</div>
)}
</div>
<div>
<h3 className="text-[13px] font-semibold mb-2">Verbindingen ({detail.edges.length})</h3>
{detail.edges.length === 0 ? (
<div className="text-muted text-[13px]">Geen verbindingen</div>
) : (
<div className="space-y-1">
{detail.edges.map((e, i) => (
<div key={e.id || i} className="flex items-center gap-2 text-[12px] text-muted bg-bg-soft border border-border-soft rounded-lg px-3 py-1.5">
<span className="text-text">{e.source}</span>
<ArrowRight className="w-3.5 h-3.5 shrink-0" />
<span className="text-text">{e.target}</span>
{e.label && <Badge color="muted" className="ml-1">{e.label}</Badge>}
</div>
))}
</div>
)}
</div>
<div className="flex justify-between items-center pt-2 border-t border-border-soft">
<Btn variant="danger" onClick={deleteDiagram} loading={deleting}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
<Btn variant="secondary" onClick={closeDetail}>Sluiten</Btn>
</div>
</div>
)}
</Modal>
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
import { useEffect, useState } from 'react';
import { RefreshCw, MailPlus, Inbox, Mail } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Badge, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table } from '../components/ui';
const EMPTY_COMPOSE = { to: '', cc: '', bcc: '', subject: '', text: '' };
const EMPTY_ACCOUNT = {
email: '', name: '', imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '',
smtp_host: '', smtp_port: 587, smtp_secure: true, smtp_user: '', smtp_pass: ''
};
function TabBar({ tabs, active, onChange }) {
return (
<div className="flex gap-1 border-b border-border-soft">
{tabs.map(t => (
<button
key={t.key}
onClick={() => onChange(t.key)}
className={`px-3.5 py-2 text-[13px] font-medium border-b-2 -mb-px transition-colors ${
active === t.key ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-text'
}`}
>
{t.label}
</button>
))}
</div>
);
}
export default function Email() {
const [tab, setTab] = useState('inbox');
// Inbox
const [messages, setMessages] = useState(null);
const [msgError, setMsgError] = useState(null);
const [syncing, setSyncing] = useState(false);
const [banner, setBanner] = useState(null); // { type: 'green'|'red', text }
const [composeOpen, setComposeOpen] = useState(false);
const [compose, setCompose] = useState(EMPTY_COMPOSE);
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState(null);
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState(null);
// Accounts
const [accounts, setAccounts] = useState(null);
const [accError, setAccError] = useState(null);
const [accModal, setAccModal] = useState(false);
const [editAcc, setEditAcc] = useState(null);
const [accForm, setAccForm] = useState(EMPTY_ACCOUNT);
const [savingAcc, setSavingAcc] = useState(false);
const [accSaveError, setAccSaveError] = useState(null);
const loadMessages = () => {
setMsgError(null);
api.get('/email/messages').then(setMessages).catch(setMsgError);
};
const loadAccounts = () => {
setAccError(null);
api.get('/email/accounts').then(setAccounts).catch(setAccError);
};
useEffect(() => { loadMessages(); loadAccounts(); }, []);
const showBanner = (type, text) => {
setBanner({ type, text });
setTimeout(() => setBanner(null), 6000);
};
// ---- Sync ----
const sync = async () => {
setSyncing(true);
setBanner(null);
try {
await api.post('/email/sync');
showBanner('green', 'Inbox gesynchroniseerd');
loadMessages();
} catch (err) {
showBanner('red', err.message || String(err));
} finally {
setSyncing(false);
}
};
// ---- Compose ----
const openCompose = () => {
setCompose(EMPTY_COMPOSE);
setSendError(null);
setComposeOpen(true);
};
const sendMail = async (e) => {
e.preventDefault();
setSending(true);
setSendError(null);
try {
await api.post('/email/send', compose);
setComposeOpen(false);
showBanner('green', 'E-mail verzonden');
} catch (err) {
setSendError(err);
} finally {
setSending(false);
}
};
// ---- Detail ----
const openDetail = (m) => {
setDetail(null);
setDetailError(null);
setDetailLoading(true);
api.get(`/email/messages/${m.id}`)
.then(res => { setDetail(res); loadMessages(); })
.catch(setDetailError)
.finally(() => setDetailLoading(false));
};
const closeDetail = () => {
setDetail(null);
setDetailError(null);
setDetailLoading(false);
};
// ---- Accounts ----
const openAccNew = () => {
setEditAcc(null);
setAccForm(EMPTY_ACCOUNT);
setAccSaveError(null);
setAccModal(true);
};
const openAccEdit = (a) => {
setEditAcc(a);
setAccForm({
email: a.email || '', name: a.name || '', imap_host: a.imap_host || '', imap_port: a.imap_port || 993,
imap_secure: !!a.imap_secure, imap_user: a.imap_user || '', imap_pass: '',
smtp_host: a.smtp_host || '', smtp_port: a.smtp_port || 587, smtp_secure: !!a.smtp_secure,
smtp_user: a.smtp_user || '', smtp_pass: ''
});
setAccSaveError(null);
setAccModal(true);
};
const saveAcc = async (e) => {
e.preventDefault();
setSavingAcc(true);
setAccSaveError(null);
try {
await api.post('/email/accounts', { ...accForm, id: editAcc ? editAcc.id : undefined });
setAccModal(false);
loadAccounts();
} catch (err) {
setAccSaveError(err);
} finally {
setSavingAcc(false);
}
};
const detailOpen = detailLoading || !!detail || !!detailError;
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight">E-mail</h1>
<p className="text-[13px] text-muted mt-0.5">Postvak IN en e-mailaccounts</p>
</div>
{tab === 'inbox' ? (
<div className="flex items-center gap-2">
<Btn variant="secondary" onClick={sync} loading={syncing}>
<RefreshCw className="w-4 h-4" /> Synchroniseren
</Btn>
<Btn onClick={openCompose}><MailPlus className="w-4 h-4" /> Nieuwe e-mail</Btn>
</div>
) : (
<Btn onClick={openAccNew}><MailPlus className="w-4 h-4" /> Account toevoegen</Btn>
)}
</div>
{banner && (
<div className={`rounded-lg px-4 py-3 text-[13px] border ${
banner.type === 'green' ? 'bg-green/10 border-green/30 text-green' : 'bg-red/10 border-red/30 text-red'
}`}>
{banner.text}
</div>
)}
<TabBar
tabs={[{ key: 'inbox', label: 'Postvak IN' }, { key: 'accounts', label: 'Accounts' }]}
active={tab}
onChange={setTab}
/>
{tab === 'inbox' && (
<Card padding={false}>
<div className="p-4">
{msgError ? <ErrorBox error={msgError} onRetry={loadMessages} /> : !messages ? <Spinner /> : (
<Table
columns={[
{
label: '', className: 'w-6',
render: m => !m.seen ? <span className="block w-2 h-2 rounded-full bg-blue" title="Ongelezen" /> : null
},
{ label: 'Van', render: m => <span className={!m.seen ? 'font-semibold' : ''}>{m.from_name || m.from_addr || '-'}</span> },
{
label: 'Onderwerp',
render: m => (
<button onClick={() => openDetail(m)} className={`text-left text-accent hover:underline ${!m.seen ? 'font-semibold' : ''}`}>
{m.subject || '(geen onderwerp)'}
</button>
)
},
{ label: 'Datum', render: m => <span className="text-muted">{fmt.datetime(m.date)}</span> },
{ label: 'Account', render: m => <span className="text-muted">{m.account_email}</span> }
]}
rows={messages}
keyFn={m => m.id}
empty={
<div className="flex flex-col items-center py-10 text-muted">
<Inbox className="w-8 h-8 mb-2" />
<div className="text-[13px]">Geen berichten synchroniseer je inbox</div>
</div>
}
/>
)}
</div>
</Card>
)}
{tab === 'accounts' && (
<Card padding={false}>
<div className="p-4">
{accError ? <ErrorBox error={accError} onRetry={loadAccounts} /> : !accounts ? <Spinner /> : (
<Table
columns={[
{ label: 'Email', render: a => <span className="font-medium flex items-center gap-2"><Mail className="w-3.5 h-3.5 text-muted" />{a.email}</span> },
{ label: 'IMAP', render: a => <span className="text-muted">{a.imap_host}:{a.imap_port}</span> },
{ label: 'SMTP', render: a => <span className="text-muted">{a.smtp_host}:{a.smtp_port}</span> },
{ label: 'Actief', render: a => <Badge color={a.active ? 'green' : 'muted'}>{a.active ? 'Actief' : 'Inactief'}</Badge> }
]}
rows={accounts}
keyFn={a => a.id}
onRowClick={openAccEdit}
empty={<div className="text-center text-muted text-[13px] py-8">Nog geen accounts geconfigureerd</div>}
/>
)}
</div>
</Card>
)}
{/* Compose-modal */}
<Modal open={composeOpen} onClose={() => setComposeOpen(false)} title="Nieuwe e-mail">
<form onSubmit={sendMail} className="space-y-3">
{sendError && <ErrorBox error={sendError} />}
<Field label="Aan *">
<input className={inputCls} value={compose.to} onChange={e => setCompose({ ...compose, to: e.target.value })} required />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="CC">
<input className={inputCls} value={compose.cc} onChange={e => setCompose({ ...compose, cc: e.target.value })} />
</Field>
<Field label="BCC">
<input className={inputCls} value={compose.bcc} onChange={e => setCompose({ ...compose, bcc: e.target.value })} />
</Field>
</div>
<Field label="Onderwerp *">
<input className={inputCls} value={compose.subject} onChange={e => setCompose({ ...compose, subject: e.target.value })} required />
</Field>
<Field label="Bericht">
<textarea rows={8} className={inputCls} value={compose.text} onChange={e => setCompose({ ...compose, text: e.target.value })} />
</Field>
<div className="flex justify-end gap-2 pt-1">
<Btn variant="secondary" type="button" onClick={() => setComposeOpen(false)}>Annuleren</Btn>
<Btn type="submit" loading={sending}>Verzenden</Btn>
</div>
</form>
</Modal>
{/* Bericht-detail-modal */}
<Modal open={detailOpen} onClose={closeDetail} title={detail ? (detail.subject || '(geen onderwerp)') : 'Bericht laden'} wide>
{detailLoading && <Spinner />}
{detailError && <ErrorBox error={detailError} />}
{detail && (
<div className="space-y-3">
<div className="text-[12px] text-muted space-y-0.5">
<div>Van: <span className="text-text">{detail.from_name || detail.from_addr}</span></div>
<div>Datum: <span className="text-text">{fmt.datetime(detail.date)}</span></div>
<div>Account: <span className="text-text">{detail.account_email}</span></div>
</div>
<div className="border-t border-border-soft pt-3 text-[13px] whitespace-pre-wrap max-h-[50vh] overflow-y-auto">
{detail.body_text || '(geen inhoud)'}
</div>
<div className="flex justify-end pt-1">
<Btn variant="secondary" onClick={closeDetail}>Sluiten</Btn>
</div>
</div>
)}
</Modal>
{/* Account-modal */}
<Modal open={accModal} onClose={() => setAccModal(false)} title={editAcc ? 'Account bewerken' : 'Account toevoegen'} wide>
<form onSubmit={saveAcc} className="space-y-3">
{accSaveError && <ErrorBox error={accSaveError} />}
<div className="grid grid-cols-2 gap-3">
<Field label="E-mailadres *">
<input type="email" className={inputCls} value={accForm.email} onChange={e => setAccForm({ ...accForm, email: e.target.value })} required />
</Field>
<Field label="Weergavenaam">
<input className={inputCls} value={accForm.name} onChange={e => setAccForm({ ...accForm, name: e.target.value })} />
</Field>
</div>
<div className="text-[12px] text-muted font-medium pt-1">IMAP (inkomend)</div>
<div className="grid grid-cols-3 gap-3">
<Field label="IMAP host *" className="col-span-2">
<input className={inputCls} value={accForm.imap_host} onChange={e => setAccForm({ ...accForm, imap_host: e.target.value })} required />
</Field>
<Field label="Poort">
<input type="number" className={inputCls} value={accForm.imap_port} onChange={e => setAccForm({ ...accForm, imap_port: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="IMAP gebruiker *">
<input className={inputCls} value={accForm.imap_user} onChange={e => setAccForm({ ...accForm, imap_user: e.target.value })} required />
</Field>
<Field label="IMAP wachtwoord *">
<input
type="password"
className={inputCls}
value={accForm.imap_pass}
onChange={e => setAccForm({ ...accForm, imap_pass: e.target.value })}
placeholder={editAcc ? 'Leeg laten om te behouden' : ''}
required={!editAcc}
/>
</Field>
</div>
<label className="flex items-center gap-2 text-[13px] cursor-pointer">
<input type="checkbox" checked={accForm.imap_secure} onChange={e => setAccForm({ ...accForm, imap_secure: e.target.checked })} className="accent-[#00d4ff]" />
IMAP via SSL/TLS
</label>
<div className="text-[12px] text-muted font-medium pt-1">SMTP (uitgaand)</div>
<div className="grid grid-cols-3 gap-3">
<Field label="SMTP host" className="col-span-2">
<input className={inputCls} value={accForm.smtp_host} onChange={e => setAccForm({ ...accForm, smtp_host: e.target.value })} placeholder="Standaard = IMAP host" />
</Field>
<Field label="Poort">
<input type="number" className={inputCls} value={accForm.smtp_port} onChange={e => setAccForm({ ...accForm, smtp_port: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="SMTP gebruiker">
<input className={inputCls} value={accForm.smtp_user} onChange={e => setAccForm({ ...accForm, smtp_user: e.target.value })} placeholder="Standaard = IMAP gebruiker" />
</Field>
<Field label="SMTP wachtwoord">
<input
type="password"
className={inputCls}
value={accForm.smtp_pass}
onChange={e => setAccForm({ ...accForm, smtp_pass: e.target.value })}
placeholder={editAcc ? 'Leeg laten om te behouden' : 'Standaard = IMAP wachtwoord'}
/>
</Field>
</div>
<label className="flex items-center gap-2 text-[13px] cursor-pointer">
<input type="checkbox" checked={accForm.smtp_secure} onChange={e => setAccForm({ ...accForm, smtp_secure: e.target.checked })} className="accent-[#00d4ff]" />
SMTP via SSL/TLS
</label>
<div className="flex justify-end gap-2 pt-1">
<Btn variant="secondary" type="button" onClick={() => setAccModal(false)}>Annuleren</Btn>
<Btn type="submit" loading={savingAcc}>Opslaan</Btn>
</div>
</form>
</Modal>
</div>
);
}
+316
View File
@@ -0,0 +1,316 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Pencil, Trash2, Plus } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table, StatusBadge, Badge } from '../components/ui';
import { TYPE_LABELS, ENG_STATUS_LABELS } from './Engagements';
import { TASK_STATUS_LABELS, PRIORITY_LABELS } from './Tasks';
const selectCls =
'bg-bg-soft border border-border rounded-lg px-3 py-2 text-[13px] focus:outline-none focus:border-accent/60 transition-colors';
const selectSmCls =
'bg-bg-soft border border-border rounded-lg px-2 py-1 text-[12px] focus:outline-none focus:border-accent/60 transition-colors shrink-0';
const EMPTY_EDIT = { type: 'assessment', title: '', description: '', start_date: '', end_date: '', status: 'planned', outcome: '' };
function EditEngagementModal({ open, onClose, engagement, onSaved }) {
const [form, setForm] = useState(EMPTY_EDIT);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open || !engagement) return;
setError(null);
setForm({
type: engagement.type || 'assessment',
title: engagement.title || '',
description: engagement.description || '',
start_date: engagement.start_date || '',
end_date: engagement.end_date || '',
status: engagement.status || 'planned',
outcome: engagement.outcome || ''
});
}, [open, engagement]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.put(`/engagements/${engagement.id}`, form);
onSaved();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Engagement bewerken">
<form onSubmit={submit} className="space-y-3">
<ErrorBox error={error} />
<div className="grid grid-cols-2 gap-3">
<Field label="Type *">
<select className={inputCls} value={form.type} onChange={set('type')} required>
{Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Status">
<select className={inputCls} value={form.status} onChange={set('status')}>
{Object.entries(ENG_STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
</div>
<Field label="Titel *">
<input className={inputCls} value={form.title} onChange={set('title')} required autoFocus />
</Field>
<Field label="Omschrijving">
<textarea className={inputCls} rows={3} value={form.description} onChange={set('description')} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Startdatum">
<input type="date" className={inputCls} value={form.start_date} onChange={set('start_date')} />
</Field>
<Field label="Einddatum">
<input type="date" className={inputCls} value={form.end_date} onChange={set('end_date')} />
</Field>
</div>
<Field label="Resultaat (outcome)">
<textarea className={inputCls} rows={2} value={form.outcome} onChange={set('outcome')} />
</Field>
<div className="flex justify-end gap-2 pt-1">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</form>
</Modal>
);
}
function TaskFormModal({ open, onClose, engagementId, onSaved }) {
const EMPTY = { title: '', priority: 'medium', due_date: '' };
const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setError(null);
setForm(EMPTY);
}, [open]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.post('/tasks', { ...form, engagement_id: engagementId, due_date: form.due_date || null });
onSaved();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Taak toevoegen">
<form onSubmit={submit} className="space-y-3">
<ErrorBox error={error} />
<Field label="Titel *">
<input className={inputCls} value={form.title} onChange={set('title')} required autoFocus />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Prioriteit">
<select className={inputCls} value={form.priority} onChange={set('priority')}>
{Object.entries(PRIORITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Deadline">
<input type="date" className={inputCls} value={form.due_date} onChange={set('due_date')} />
</Field>
</div>
<div className="flex justify-end gap-2 pt-1">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Aanmaken</Btn>
</div>
</form>
</Modal>
);
}
export default function EngagementDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [editOpen, setEditOpen] = useState(false);
const [taskModalOpen, setTaskModalOpen] = useState(false);
const load = () => {
setError(null);
api.get(`/engagements/${id}`).then(setData).catch(setError);
};
useEffect(load, [id]);
if (!data) {
return error ? <ErrorBox error={error} onRetry={load} /> : <Spinner />;
}
const { engagement, tasks, timeEntries } = data;
const changeStatus = async (e) => {
try {
await api.post(`/engagements/${id}/status`, { status: e.target.value });
load();
} catch (err) {
setError(err);
}
};
const remove = async () => {
if (!window.confirm(`Engagement "${engagement.title}" verwijderen? Dit kan niet ongedaan worden gemaakt.`)) return;
try {
await api.del(`/engagements/${id}`);
navigate('/engagements');
} catch (err) {
setError(err);
}
};
const setTaskStatus = async (taskId, status) => {
try {
await api.post(`/tasks/${taskId}/status`, { status });
load();
} catch (err) {
setError(err);
}
};
const removeTask = async (t) => {
if (!window.confirm(`Taak "${t.title}" verwijderen?`)) return;
try {
await api.del(`/tasks/${t.id}`);
load();
} catch (err) {
setError(err);
}
};
return (
<div className="space-y-4 fade-in">
{error && <ErrorBox error={error} onRetry={load} />}
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl font-bold tracking-tight">{engagement.title}</h1>
<StatusBadge status={engagement.status} labels={ENG_STATUS_LABELS} />
<Badge color="accent">{TYPE_LABELS[engagement.type] || engagement.type}</Badge>
</div>
<div className="flex items-center gap-3 mt-1.5 text-[13px] text-muted flex-wrap">
<Link to={`/clients/${engagement.client_id}`} className="text-accent hover:underline">
{engagement.client_name}
</Link>
<span>{fmt.date(engagement.start_date)} {fmt.date(engagement.end_date)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<select className={selectCls} value={engagement.status} onChange={changeStatus} title="Status wijzigen">
{Object.entries(ENG_STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<Btn variant="secondary" onClick={() => setEditOpen(true)}>
<Pencil className="w-3.5 h-3.5" /> Bewerken
</Btn>
<Btn variant="danger" onClick={remove}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
</div>
</div>
{(engagement.description || engagement.outcome) && (
<Card title="Details">
{engagement.description && <p className="text-[13px] whitespace-pre-wrap">{engagement.description}</p>}
{engagement.outcome && (
<div className={engagement.description ? 'mt-3 pt-3 border-t border-border-soft' : ''}>
<div className="text-[12px] text-muted mb-1">Resultaat</div>
<p className="text-[13px] whitespace-pre-wrap">{engagement.outcome}</p>
</div>
)}
</Card>
)}
{/* Taken */}
<Card
title={`Taken (${tasks.length})`}
actions={<Btn size="sm" onClick={() => setTaskModalOpen(true)}><Plus className="w-3.5 h-3.5" /> Taak toevoegen</Btn>}
>
{tasks.length === 0 ? (
<div className="text-muted text-[13px] py-4 text-center">Nog geen taken</div>
) : (
<div className="space-y-1.5">
{tasks.map((t) => (
<div key={t.id} className="flex items-center gap-3 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
<select
className={selectSmCls}
value={t.status}
onChange={(e) => setTaskStatus(t.id, e.target.value)}
>
{Object.entries(TASK_STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium truncate">{t.title}</div>
{t.due_date && <div className="text-[11px] text-muted">Deadline: {fmt.date(t.due_date)}</div>}
</div>
<StatusBadge status={t.priority} labels={PRIORITY_LABELS} />
<button
onClick={() => removeTask(t)}
className="p-1.5 rounded-md text-muted hover:text-red hover:bg-bg-soft shrink-0"
title="Verwijderen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</Card>
{/* Uren */}
<Card title={`Uren (${timeEntries.length})`}>
<Table
columns={[
{ label: 'Datum', render: (t) => fmt.date(t.date) },
{ label: 'Gebruiker', render: (t) => t.username || <span className="text-muted">-</span> },
{ label: 'Uren', render: (t) => fmt.hours(t.hours) },
{ label: 'Omschrijving', render: (t) => t.description || <span className="text-muted">-</span> },
{ label: 'Facturabel', render: (t) => <Badge color={t.billable ? 'green' : 'muted'}>{t.billable ? 'Ja' : 'Nee'}</Badge> }
]}
rows={timeEntries}
keyFn={(t) => t.id}
empty={<div className="text-muted text-[13px] py-4 text-center">Nog geen uren geregistreerd</div>}
/>
</Card>
<EditEngagementModal
open={editOpen}
engagement={engagement}
onClose={() => setEditOpen(false)}
onSaved={() => { setEditOpen(false); load(); }}
/>
<TaskFormModal
open={taskModalOpen}
engagementId={engagement.id}
onClose={() => setTaskModalOpen(false)}
onSaved={() => { setTaskModalOpen(false); load(); }}
/>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Briefcase } from 'lucide-react';
import { api, qs, fmt } from '../api';
import { Card, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table, StatusBadge, EmptyState } from '../components/ui';
export const TYPE_LABELS = {
assessment: 'Assessment', architecture: 'Architectuur', implementation: 'Implementatie',
monitoring: 'Monitoring', consulting: 'Consulting', training: 'Training'
};
export const ENG_STATUS_LABELS = { planned: 'Gepland', in_progress: 'Lopend', completed: 'Afgerond', on_hold: 'On hold' };
const selectCls =
'bg-bg-soft border border-border rounded-lg px-3 py-2 text-[13px] focus:outline-none focus:border-accent/60 transition-colors';
const EMPTY_FORM = { client_id: '', type: 'assessment', title: '', description: '', start_date: '', end_date: '' };
export function EngagementFormModal({ open, onClose, clients = [], clientId, onSaved }) {
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setError(null);
setForm({ ...EMPTY_FORM, client_id: clientId ? String(clientId) : '' });
}, [open, clientId]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.post('/engagements', { ...form, client_id: clientId || form.client_id });
onSaved();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Nieuw engagement">
<form onSubmit={submit} className="space-y-3">
<ErrorBox error={error} />
{!clientId && (
<Field label="Cliënt *">
<select className={inputCls} value={form.client_id} onChange={set('client_id')} required>
<option value=""> Kies een cliënt </option>
{clients.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
)}
<Field label="Type *">
<select className={inputCls} value={form.type} onChange={set('type')} required>
{Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Titel *">
<input className={inputCls} value={form.title} onChange={set('title')} required autoFocus />
</Field>
<Field label="Omschrijving">
<textarea className={inputCls} rows={3} value={form.description} onChange={set('description')} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Startdatum">
<input type="date" className={inputCls} value={form.start_date} onChange={set('start_date')} />
</Field>
<Field label="Einddatum">
<input type="date" className={inputCls} value={form.end_date} onChange={set('end_date')} />
</Field>
</div>
<div className="flex justify-end gap-2 pt-1">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Aanmaken</Btn>
</div>
</form>
</Modal>
);
}
export default function Engagements() {
const [data, setData] = useState(null);
const [clients, setClients] = useState([]);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({ type: '', status: '', client_id: '' });
const [modalOpen, setModalOpen] = useState(false);
const load = () => {
setError(null);
api.get('/engagements' + qs(filters)).then(setData).catch(setError);
};
useEffect(load, [filters.type, filters.status, filters.client_id]);
useEffect(() => {
api.get('/clients').then(setClients).catch(() => { /* filter-opties zijn optioneel */ });
}, []);
const setFilter = (k) => (e) => setFilters((f) => ({ ...f, [k]: e.target.value }));
return (
<div className="space-y-4 fade-in">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-xl font-bold tracking-tight">Engagements</h1>
<p className="text-[13px] text-muted mt-0.5">Alle opdrachten en projecten</p>
</div>
<Btn onClick={() => setModalOpen(true)}>
<Plus className="w-4 h-4" /> Nieuw engagement
</Btn>
</div>
<div className="flex items-center gap-2 flex-wrap">
<select className={selectCls} value={filters.type} onChange={setFilter('type')}>
<option value="">Alle types</option>
{Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select className={selectCls} value={filters.status} onChange={setFilter('status')}>
<option value="">Alle statussen</option>
{Object.entries(ENG_STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select className={selectCls} value={filters.client_id} onChange={setFilter('client_id')}>
<option value="">Alle cliënten</option>
{clients.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{error && <ErrorBox error={error} onRetry={load} />}
{!data && !error && <Spinner />}
{data && (
<Card padding={false}>
<div className="p-4">
<Table
columns={[
{
label: 'Titel',
render: (e) => (
<Link to={`/engagements/${e.id}`} className="font-medium text-text hover:text-accent transition-colors">
{e.title}
</Link>
)
},
{
label: 'Cliënt',
render: (e) => (
<Link to={`/clients/${e.client_id}`} className="text-muted hover:text-accent transition-colors">
{e.client_name}
</Link>
)
},
{ label: 'Type', render: (e) => TYPE_LABELS[e.type] || e.type },
{ label: 'Status', render: (e) => <StatusBadge status={e.status} labels={ENG_STATUS_LABELS} /> },
{ label: 'Taken', render: (e) => e.task_count },
{ label: 'Start', render: (e) => <span className="text-muted">{fmt.date(e.start_date)}</span> },
{ label: 'Eind', render: (e) => <span className="text-muted">{fmt.date(e.end_date)}</span> }
]}
rows={data}
keyFn={(e) => e.id}
empty={<EmptyState icon={Briefcase} title="Geen engagements gevonden" hint="Pas de filters aan of maak een nieuw engagement aan." />}
/>
</div>
</Card>
)}
<EngagementFormModal
open={modalOpen}
onClose={() => setModalOpen(false)}
clients={clients}
onSaved={() => { setModalOpen(false); load(); }}
/>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { Clock, Wallet, Receipt, FileText } from 'lucide-react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
import { api, qs, fmt } from '../api';
import { Card, KpiCard, Spinner, ErrorBox } from '../components/ui';
const tooltipStyle = {
contentStyle: { backgroundColor: '#131a2a', border: '1px solid #1f2a44', borderRadius: 8, fontSize: 12 },
labelStyle: { color: '#8b9bb8' }
};
const THIS_YEAR = new Date().getFullYear();
const YEARS = [THIS_YEAR - 2, THIS_YEAR - 1, THIS_YEAR];
export default function Finance() {
const [year, setYear] = useState(THIS_YEAR);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const load = () => {
setError(null);
api.get('/finance' + qs({ year })).then(setData).catch(setError);
};
useEffect(load, [year]);
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-xl font-bold tracking-tight">Financiën</h1>
<p className="text-[13px] text-muted mt-0.5">Uren en omzet over {data?.year ?? year}</p>
</div>
<select
className="bg-bg-soft border border-border rounded-lg px-3 py-2 text-[13px] focus:outline-none focus:border-accent/60"
value={year}
onChange={e => setYear(parseInt(e.target.value))}
>
{YEARS.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{error ? (
<ErrorBox error={error} onRetry={load} />
) : !data ? (
<Spinner />
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<KpiCard icon={Clock} label="Uren dit jaar" value={fmt.hours(data.totals.hoursThisYear)} color="blue" />
<KpiCard icon={Wallet} label="Betaald dit jaar" value={fmt.euro(data.totals.paidThisYear)} color="green" />
<KpiCard icon={Receipt} label="Openstaand totaal" value={fmt.euro(data.totals.outstanding)} color="yellow" />
<KpiCard icon={FileText} label="Concept-facturen" value={fmt.euro(data.totals.draftTotal)} sub="nog niet verzonden" color="accent" />
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Card title="Uren per maand" subtitle={`Totaal vs facturabel (${data.year})`}>
<div className="h-56">
{data.timeStats.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted text-[13px]">Geen uren in {data.year}</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.timeStats}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2a44" vertical={false} />
<XAxis dataKey="month" tick={{ fill: '#8b9bb8', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#8b9bb8', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip {...tooltipStyle} cursor={{ fill: 'rgba(0,212,255,0.06)' }} />
<Bar dataKey="hours" name="Totaal" fill="#58a6ff" radius={[4, 4, 0, 0]} />
<Bar dataKey="billable_hours" name="Facturabel" fill="#00d4ff" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</Card>
<Card title="Omzet per maand" subtitle={`Betaald vs openstaand (${data.year})`}>
<div className="h-56">
{data.invoiceStats.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted text-[13px]">Geen facturen in {data.year}</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.invoiceStats}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2a44" vertical={false} />
<XAxis dataKey="month" tick={{ fill: '#8b9bb8', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#8b9bb8', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={v => `${v}`} />
<Tooltip {...tooltipStyle} formatter={(v) => fmt.euro(v)} cursor={{ fill: 'rgba(0,212,255,0.06)' }} />
<Bar dataKey="paid" name="Betaald" fill="#3fb950" radius={[4, 4, 0, 0]} />
<Bar dataKey="outstanding" name="Openstaand" fill="#d29922" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</Card>
</div>
</>
)}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Mail, Trash2 } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Btn, Spinner, ErrorBox, StatusBadge } from '../components/ui';
const STATUS_LABELS = {
draft: 'Concept', sent: 'Verzonden', paid: 'Betaald', overdue: 'Verlopen', cancelled: 'Geannuleerd'
};
export default function InvoiceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [banner, setBanner] = useState(null);
const [sending, setSending] = useState(false);
const [statusSaving, setStatusSaving] = useState(false);
const load = () => {
setError(null);
api.get(`/invoices/${id}`).then(setData).catch(setError);
};
useEffect(load, [id]);
const changeStatus = async (status) => {
setStatusSaving(true);
setBanner(null);
try {
await api.post(`/invoices/${id}/status`, { status });
load();
} catch (err) {
setBanner({ type: 'error', msg: err.message });
} finally {
setStatusSaving(false);
}
};
const sendEmail = async () => {
if (!window.confirm('Factuur per e-mail versturen naar de cliënt?')) return;
setSending(true);
setBanner(null);
try {
await api.post(`/invoices/${id}/send-email`);
setBanner({ type: 'success', msg: 'Factuur is succesvol per e-mail verstuurd.' });
load();
} catch (err) {
setBanner({ type: 'error', msg: err.message });
} finally {
setSending(false);
}
};
const remove = async () => {
if (!window.confirm(`Factuur ${data.invoice.number} definitief verwijderen?`)) return;
try {
await api.del(`/invoices/${id}`);
navigate('/invoices');
} catch (err) {
setBanner({ type: 'error', msg: err.message });
}
};
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!data) return <Spinner />;
const { invoice, items } = data;
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Link to="/invoices">
<Btn variant="secondary" size="sm"><ArrowLeft className="w-3.5 h-3.5" /> Terug</Btn>
</Link>
<h1 className="text-xl font-bold tracking-tight">{invoice.number}</h1>
<StatusBadge status={invoice.status} labels={STATUS_LABELS} />
</div>
<div className="flex items-center gap-2 flex-wrap">
<select
className="bg-bg-soft border border-border rounded-lg px-3 py-2 text-[13px] focus:outline-none focus:border-accent/60 disabled:opacity-50"
value={invoice.status}
disabled={statusSaving}
onChange={e => changeStatus(e.target.value)}
>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<Btn variant="secondary" onClick={sendEmail} loading={sending}>
<Mail className="w-3.5 h-3.5" /> E-mail versturen
</Btn>
<Btn variant="danger" onClick={remove}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
</div>
</div>
{banner && (
<div className={`rounded-lg px-4 py-3 text-[13px] border ${
banner.type === 'success'
? 'bg-green/10 border-green/30 text-green'
: 'bg-red/10 border-red/30 text-red'
}`}>
{banner.msg}
</div>
)}
<Card padding={false} className="max-w-3xl">
<div className="p-6 space-y-6">
{/* Factuurkop */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="text-[11px] uppercase tracking-wide text-muted mb-1">Factuur voor</div>
<div className="text-[15px] font-semibold">{invoice.client_name}</div>
<div className="text-[12px] text-muted mt-1 space-y-0.5">
{invoice.contact_name && <div>{invoice.contact_name}</div>}
{invoice.contact_email && <div>{invoice.contact_email}</div>}
{invoice.website && <div>{invoice.website}</div>}
</div>
</div>
<div className="text-right text-[13px] space-y-1">
<div>
<span className="text-muted">Factuurnummer: </span>
<span className="font-medium">{invoice.number}</span>
</div>
<div>
<span className="text-muted">Factuurdatum: </span>
<span>{fmt.date(invoice.date)}</span>
</div>
<div>
<span className="text-muted">Vervaldatum: </span>
<span>{invoice.due_date ? fmt.date(invoice.due_date) : '-'}</span>
</div>
</div>
</div>
{/* Items */}
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="text-left text-muted border-b border-border-soft">
<th className="pb-2 pr-4 font-medium text-[12px]">Omschrijving</th>
<th className="pb-2 pr-4 font-medium text-[12px] text-right">Aantal</th>
<th className="pb-2 pr-4 font-medium text-[12px] text-right">Tarief</th>
<th className="pb-2 font-medium text-[12px] text-right">Totaal</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id} className="border-b border-border-soft/50">
<td className="py-2.5 pr-4">{item.description}</td>
<td className="py-2.5 pr-4 text-right text-muted">{(item.quantity || 0).toLocaleString('nl-NL')}</td>
<td className="py-2.5 pr-4 text-right text-muted">{fmt.euro(item.unit_price)}</td>
<td className="py-2.5 text-right">{fmt.euro(item.total)}</td>
</tr>
))}
{items.length === 0 && (
<tr><td colSpan={4} className="py-6 text-center text-muted">Geen factuurregels</td></tr>
)}
</tbody>
</table>
</div>
{/* Totalen */}
<div className="flex justify-end">
<div className="w-64 space-y-1.5 text-[13px]">
<div className="flex justify-between">
<span className="text-muted">Subtotaal</span>
<span>{fmt.euro(invoice.subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted">BTW</span>
<span>{fmt.euro(invoice.tax)}</span>
</div>
<div className="flex justify-between font-bold text-[15px] border-t border-border-soft pt-1.5">
<span>Totaal</span>
<span>{fmt.euro(invoice.total)}</span>
</div>
</div>
</div>
{invoice.notes && (
<div className="border-t border-border-soft pt-4">
<div className="text-[11px] uppercase tracking-wide text-muted mb-1">Notities</div>
<div className="text-[13px] whitespace-pre-wrap">{invoice.notes}</div>
</div>
)}
</div>
</Card>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Receipt, Plus, Trash2, Wallet, FileText } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, KpiCard, Btn, Field, inputCls, Modal, Spinner, EmptyState, ErrorBox, Table, StatusBadge } from '../components/ui';
const today = () => new Date().toISOString().split('T')[0];
const STATUS_LABELS = {
draft: 'Concept', sent: 'Verzonden', paid: 'Betaald', overdue: 'Verlopen', cancelled: 'Geannuleerd'
};
const EMPTY_ITEM = { description: '', quantity: 1, unit_price: '' };
function NewInvoiceModal({ open, onClose, clients, onSaved }) {
const [form, setForm] = useState({ client_id: '', date: '', due_date: '', notes: '' });
const [items, setItems] = useState([{ ...EMPTY_ITEM }]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setForm({ client_id: '', date: today(), due_date: '', notes: '' });
setItems([{ ...EMPTY_ITEM }]);
setError(null);
}, [open]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const setItem = (i, k, v) => setItems(list => list.map((it, idx) => idx === i ? { ...it, [k]: v } : it));
const addItem = () => setItems(list => [...list, { ...EMPTY_ITEM }]);
const removeItem = (i) => setItems(list => list.filter((_, idx) => idx !== i));
const subtotal = useMemo(
() => items.reduce((s, it) => s + (parseFloat(it.quantity) || 0) * (parseFloat(it.unit_price) || 0), 0),
[items]
);
const tax = subtotal * 0.21;
const total = subtotal + tax;
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.post('/invoices', {
client_id: form.client_id,
date: form.date,
due_date: form.due_date,
notes: form.notes,
items: items.filter(it => it.description.trim()),
subtotal,
tax
});
onSaved();
onClose();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Nieuwe factuur" wide>
<form onSubmit={submit} className="space-y-4">
<ErrorBox error={error} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Field label="Cliënt *">
<select required className={inputCls} value={form.client_id} onChange={e => set('client_id', e.target.value)}>
<option value=""> Kies een cliënt </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<Field label="Factuurdatum">
<input type="date" className={inputCls} value={form.date} onChange={e => set('date', e.target.value)} />
</Field>
<Field label="Vervaldatum">
<input type="date" className={inputCls} value={form.due_date} onChange={e => set('due_date', e.target.value)} />
</Field>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] text-muted">Factuurregels</span>
<Btn type="button" variant="secondary" size="sm" onClick={addItem}>
<Plus className="w-3 h-3" /> Regel toevoegen
</Btn>
</div>
<div className="space-y-2">
{items.map((it, i) => (
<div key={i} className="flex items-center gap-2">
<input
className={`${inputCls} flex-1`}
placeholder="Omschrijving"
value={it.description}
onChange={e => setItem(i, 'description', e.target.value)}
/>
<input
type="number" min="0" step="0.25"
className={`${inputCls} !w-24`}
placeholder="Aantal"
value={it.quantity}
onChange={e => setItem(i, 'quantity', e.target.value)}
/>
<input
type="number" min="0" step="0.01"
className={`${inputCls} !w-28`}
placeholder="Prijs"
value={it.unit_price}
onChange={e => setItem(i, 'unit_price', e.target.value)}
/>
<span className="text-[12px] text-muted w-24 text-right shrink-0">{fmt.euro((parseFloat(it.quantity) || 0) * (parseFloat(it.unit_price) || 0))}</span>
<button
type="button"
onClick={() => removeItem(i)}
disabled={items.length === 1}
className="p-1.5 rounded-md text-muted hover:text-red hover:bg-red/10 disabled:opacity-30 disabled:cursor-not-allowed shrink-0"
title="Regel verwijderen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<div className="w-64 space-y-1.5 text-[13px]">
<div className="flex justify-between">
<span className="text-muted">Subtotaal</span>
<span>{fmt.euro(subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted">BTW (21%)</span>
<span>{fmt.euro(tax)}</span>
</div>
<div className="flex justify-between font-semibold border-t border-border-soft pt-1.5">
<span>Totaal</span>
<span>{fmt.euro(total)}</span>
</div>
</div>
</div>
<Field label="Notities">
<textarea className={inputCls} rows={2} value={form.notes} onChange={e => set('notes', e.target.value)} placeholder="Bijv. betalingsvoorwaarden" />
</Field>
<div className="flex justify-end gap-2 pt-1">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Factuur aanmaken</Btn>
</div>
</form>
</Modal>
);
}
export default function Invoices() {
const [data, setData] = useState(null);
const [clients, setClients] = useState([]);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const navigate = useNavigate();
useEffect(() => {
api.get('/clients').then(setClients).catch(() => {});
}, []);
const load = () => {
setError(null);
api.get('/invoices').then(setData).catch(setError);
};
useEffect(load, []);
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-xl font-bold tracking-tight">Facturen</h1>
<p className="text-[13px] text-muted mt-0.5">Beheer je facturatie en betalingen</p>
</div>
<Btn onClick={() => setModalOpen(true)}>
<Plus className="w-3.5 h-3.5" /> Nieuwe factuur
</Btn>
</div>
{error ? (
<ErrorBox error={error} onRetry={load} />
) : !data ? (
<Spinner />
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<KpiCard icon={Receipt} label="Openstaand" value={fmt.euro(data.totalOutstanding)} color="yellow" />
<KpiCard icon={Wallet} label="Betaald" value={fmt.euro(data.totalPaid)} color="green" />
<KpiCard icon={FileText} label="Totaal facturen" value={data.invoices.length} color="accent" />
</div>
<Card>
<Table
columns={[
{
label: 'Nummer',
render: r => (
<Link to={`/invoices/${r.id}`} onClick={e => e.stopPropagation()} className="font-medium text-accent hover:underline">
{r.number}
</Link>
)
},
{ label: 'Cliënt', key: 'client_name' },
{ label: 'Datum', render: r => <span className="text-muted">{fmt.date(r.date)}</span> },
{ label: 'Vervaldatum', render: r => <span className="text-muted">{r.due_date ? fmt.date(r.due_date) : '-'}</span> },
{ label: 'Totaal', render: r => fmt.euro(r.total) },
{ label: 'Status', render: r => <StatusBadge status={r.status} labels={STATUS_LABELS} /> }
]}
rows={data.invoices}
keyFn={r => r.id}
onRowClick={r => navigate(`/invoices/${r.id}`)}
empty={<EmptyState icon={Receipt} title="Nog geen facturen" hint="Maak je eerste factuur aan of genereer er een uit je uren." />}
/>
</Card>
</>
)}
<NewInvoiceModal open={modalOpen} onClose={() => setModalOpen(false)} clients={clients} onSaved={load} />
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useState } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
import { Network } from 'lucide-react';
import { useAuth } from '../auth';
import { Btn, Field, inputCls } from '../components/ui';
export default function Login() {
const { user, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
if (user) {
const from = location.state?.from?.pathname || '/';
return <Navigate to={from} replace />;
}
const submit = async (e) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(username, password);
navigate('/', { replace: true });
} catch (err) {
setError(err.message || 'Inloggen mislukt');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-full flex items-center justify-center p-4 bg-bg">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="w-14 h-14 rounded-2xl bg-accent-soft flex items-center justify-center glow-accent mb-4">
<Network className="w-7 h-7 text-accent" />
</div>
<h1 className="text-xl font-bold tracking-tight">Mek-Tech</h1>
<p className="text-[13px] text-muted mt-1">AI Consultancy & Data Engineering</p>
</div>
<form onSubmit={submit} className="bg-card border border-border-soft rounded-xl p-6 space-y-4">
{error && (
<div className="bg-red/10 border border-red/30 rounded-lg px-3 py-2 text-[13px] text-red">
{error}
</div>
)}
<Field label="Gebruikersnaam">
<input
className={inputCls}
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
/>
</Field>
<Field label="Wachtwoord">
<input
type="password"
className={inputCls}
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</Field>
<Btn type="submit" loading={loading} className="w-full" size="lg">
Inloggen
</Btn>
</form>
<p className="text-center text-[11px] text-muted mt-6">Mek-Tech v3.0 React Edition</p>
</div>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { Building2, Factory } from 'lucide-react';
import { api, qs } from '../api';
import { Card, Badge, inputCls, Spinner, ErrorBox, Table } from '../components/ui';
function TabBar({ tabs, active, onChange }) {
return (
<div className="flex gap-1 border-b border-border-soft">
{tabs.map(t => (
<button
key={t.key}
onClick={() => onChange(t.key)}
className={`px-3.5 py-2 text-[13px] font-medium border-b-2 -mb-px transition-colors ${
active === t.key ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-text'
}`}
>
{t.label}
</button>
))}
</div>
);
}
function leadScoreColor(score) {
if (score >= 70) return 'green';
if (score >= 40) return 'yellow';
return 'muted';
}
export default function Market() {
const [tab, setTab] = useState('datacenters');
const [filters, setFilters] = useState(null);
// Datacenters
const [dcFilters, setDcFilters] = useState({ search: '', vendor: '', region: '', type: '', sort: '' });
const [datacenters, setDatacenters] = useState(null);
const [dcError, setDcError] = useState(null);
// Companies
const [coFilters, setCoFilters] = useState({ search: '', industry: '', data_warehouse: '', city: '', sort: '' });
const [companies, setCompanies] = useState(null);
const [coError, setCoError] = useState(null);
useEffect(() => {
api.get('/market/filters').then(setFilters).catch(() => setFilters({}));
}, []);
useEffect(() => {
setDcError(null);
setDatacenters(null);
api.get(`/market/datacenters${qs(dcFilters)}`).then(setDatacenters).catch(setDcError);
}, [dcFilters]);
useEffect(() => {
setCoError(null);
setCompanies(null);
api.get(`/market/companies${qs(coFilters)}`).then(setCompanies).catch(setCoError);
}, [coFilters]);
return (
<div className="space-y-5 fade-in">
<div>
<h1 className="text-xl font-bold tracking-tight">Marktverkenning</h1>
<p className="text-[13px] text-muted mt-0.5">Datacenters en tech-bedrijven in de regio</p>
</div>
<TabBar
tabs={[{ key: 'datacenters', label: 'Datacenters' }, { key: 'companies', label: 'Tech-bedrijven' }]}
active={tab}
onChange={setTab}
/>
{tab === 'datacenters' && (
<Card padding={false}>
<div className="p-4 space-y-4">
{/* Filterbalk */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<input
className={inputCls}
placeholder="Zoeken..."
value={dcFilters.search}
onChange={e => setDcFilters({ ...dcFilters, search: e.target.value })}
/>
<select className={inputCls} value={dcFilters.vendor} onChange={e => setDcFilters({ ...dcFilters, vendor: e.target.value })}>
<option value="">Alle vendors</option>
{(filters?.vendors || []).map(v => <option key={v} value={v}>{v}</option>)}
</select>
<select className={inputCls} value={dcFilters.region} onChange={e => setDcFilters({ ...dcFilters, region: e.target.value })}>
<option value="">Alle regio's</option>
{(filters?.regions || []).map(r => <option key={r} value={r}>{r}</option>)}
</select>
<select className={inputCls} value={dcFilters.type} onChange={e => setDcFilters({ ...dcFilters, type: e.target.value })}>
<option value="">Alle types</option>
{(filters?.types || []).map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select className={inputCls} value={dcFilters.sort} onChange={e => setDcFilters({ ...dcFilters, sort: e.target.value })}>
<option value="">Sorteer: vermogen</option>
<option value="name">Sorteer: naam</option>
</select>
</div>
{dcError ? <ErrorBox error={dcError} /> : !datacenters ? <Spinner /> : (
<Table
columns={[
{ label: 'Naam', render: d => <span className="font-medium flex items-center gap-2"><Building2 className="w-3.5 h-3.5 text-muted" />{d.name}</span> },
{ label: 'Stad', key: 'city' },
{ label: 'Regio', render: d => <span className="text-muted">{d.region || '-'}</span> },
{ label: 'Type', render: d => <span className="text-muted">{d.type || '-'}</span> },
{ label: 'Operator', render: d => <span className="text-muted">{d.operator || '-'}</span> },
{ label: 'Tier', render: d => d.tier ? <Badge color="accent">{d.tier}</Badge> : <span className="text-muted">-</span> },
{ label: 'Vermogen', render: d => d.power_mw != null ? `${d.power_mw} MW` : '-' },
{ label: 'Vendors', render: d => <span className="text-muted block max-w-[220px] truncate">{d.network_vendors || '-'}</span> }
]}
rows={datacenters}
keyFn={d => d.id}
empty={<div className="text-center text-muted text-[13px] py-8">Geen datacenters gevonden</div>}
/>
)}
</div>
</Card>
)}
{tab === 'companies' && (
<Card padding={false}>
<div className="p-4 space-y-4">
{/* Filterbalk */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<input
className={inputCls}
placeholder="Zoeken..."
value={coFilters.search}
onChange={e => setCoFilters({ ...coFilters, search: e.target.value })}
/>
<select className={inputCls} value={coFilters.industry} onChange={e => setCoFilters({ ...coFilters, industry: e.target.value })}>
<option value="">Alle branches</option>
{(filters?.industries || []).map(i => <option key={i} value={i}>{i}</option>)}
</select>
<select className={inputCls} value={coFilters.data_warehouse} onChange={e => setCoFilters({ ...coFilters, data_warehouse: e.target.value })}>
<option value="">Alle data warehouses</option>
{(filters?.data_warehouses || []).map(w => <option key={w} value={w}>{w}</option>)}
</select>
<select className={inputCls} value={coFilters.city} onChange={e => setCoFilters({ ...coFilters, city: e.target.value })}>
<option value="">Alle steden</option>
{(filters?.cities || []).map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select className={inputCls} value={coFilters.sort} onChange={e => setCoFilters({ ...coFilters, sort: e.target.value })}>
<option value="">Sorteer: lead score</option>
<option value="name">Sorteer: naam</option>
<option value="employees">Sorteer: werknemers</option>
</select>
</div>
{coError ? <ErrorBox error={coError} /> : !companies ? <Spinner /> : (
<Table
columns={[
{ label: 'Naam', render: c => <span className="font-medium flex items-center gap-2"><Factory className="w-3.5 h-3.5 text-muted" />{c.name}</span> },
{ label: 'Branche', render: c => <span className="text-muted">{c.industry || '-'}</span> },
{ label: 'Stad', render: c => <span className="text-muted">{c.city || '-'}</span> },
{ label: 'Werknemers', render: c => c.employees ?? '-' },
{ label: 'Data warehouse', render: c => <span className="text-muted block max-w-[160px] truncate">{c.data_warehouse || '-'}</span> },
{ label: 'BI-tools', render: c => <span className="text-muted block max-w-[140px] truncate">{c.bi_tools || '-'}</span> },
{ label: 'Cloud', render: c => <span className="text-muted">{c.cloud_provider || '-'}</span> },
{ label: 'Lead score', render: c => <Badge color={leadScoreColor(c.lead_score)}>{c.lead_score ?? '-'}</Badge> },
{ label: 'Lead status', render: c => <span className="text-muted">{c.lead_status || '-'}</span> }
]}
rows={companies}
keyFn={c => c.id}
empty={<div className="text-center text-muted text-[13px] py-8">Geen bedrijven gevonden</div>}
/>
)}
</div>
</Card>
)}
</div>
);
}
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2, Network, Cable } from 'lucide-react';
import { api } from '../api';
import { Card, Badge, StatusBadge, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table } from '../components/ui';
const STATUS_LABELS = { active: 'Actief', standby: 'Standby', decommissioned: 'Uitgefaseerd' };
const DEVICE_TYPES = ['switch', 'router', 'firewall', 'loadbalancer', 'ap', 'other'];
const CONN_TYPES = { rack_device: 'Rack-device', network_device: 'Netwerkdevice', external: 'Extern' };
const MEDIA_TYPES = ['copper', 'sfp', 'sfp+', 'qsfp', 'dac', 'fiber'];
const EMPTY_DEVICE = {
client_id: '', name: '', device_type: 'switch', model: '', manufacturer: '',
ip_address: '', mgmt_ip: '', ports_count: 24, os_version: '', status: 'active', notes: ''
};
const EMPTY_CONN = {
client_id: '', name: '', from_type: 'network_device', from_id: '', from_port: '',
to_type: 'network_device', to_id: '', to_port: '', media_type: 'copper',
speed: '1GbE', vlan: '', status: 'active', notes: ''
};
function TabBar({ tabs, active, onChange }) {
return (
<div className="flex gap-1 border-b border-border-soft">
{tabs.map(t => (
<button
key={t.key}
onClick={() => onChange(t.key)}
className={`px-3.5 py-2 text-[13px] font-medium border-b-2 -mb-px transition-colors ${
active === t.key ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-text'
}`}
>
{t.label}
</button>
))}
</div>
);
}
export default function Networking() {
const [tab, setTab] = useState('devices');
const [clients, setClients] = useState([]);
// Devices state
const [devices, setDevices] = useState(null);
const [devError, setDevError] = useState(null);
const [devModal, setDevModal] = useState(false);
const [editDev, setEditDev] = useState(null);
const [devForm, setDevForm] = useState(EMPTY_DEVICE);
// Connections state
const [conns, setConns] = useState(null);
const [connError, setConnError] = useState(null);
const [connModal, setConnModal] = useState(false);
const [editConn, setEditConn] = useState(null);
const [connForm, setConnForm] = useState(EMPTY_CONN);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const loadDevices = () => {
setDevError(null);
api.get('/networking/devices').then(setDevices).catch(setDevError);
};
const loadConns = () => {
setConnError(null);
api.get('/networking/connections').then(setConns).catch(setConnError);
};
useEffect(() => { loadDevices(); loadConns(); }, []);
useEffect(() => { api.get('/clients').then(setClients).catch(() => {}); }, []);
// ---- Device modal ----
const openDevNew = () => {
setEditDev(null);
setDevForm(EMPTY_DEVICE);
setSaveError(null);
setDevModal(true);
};
const openDevEdit = (d) => {
setEditDev(d);
setDevForm({
client_id: d.client_id, name: d.name || '', device_type: d.device_type || 'switch',
model: d.model || '', manufacturer: d.manufacturer || '', ip_address: d.ip_address || '',
mgmt_ip: d.mgmt_ip || '', ports_count: d.ports_count ?? 24, os_version: d.os_version || '',
status: d.status || 'active', notes: d.notes || ''
});
setSaveError(null);
setDevModal(true);
};
const saveDev = async (e) => {
e.preventDefault();
setSaving(true);
setSaveError(null);
const body = { ...devForm, ports_count: parseInt(devForm.ports_count, 10) || 24 };
try {
if (editDev) {
await api.put(`/networking/devices/${editDev.id}`, body);
} else {
await api.post('/networking/devices', body);
}
setDevModal(false);
loadDevices();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
const deleteDev = async () => {
if (!editDev) return;
if (!window.confirm(`Device "${editDev.name}" verwijderen?`)) return;
setSaving(true);
setSaveError(null);
try {
await api.del(`/networking/devices/${editDev.id}`);
setDevModal(false);
loadDevices();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
// ---- Connection modal ----
const openConnNew = () => {
setEditConn(null);
setConnForm(EMPTY_CONN);
setSaveError(null);
setConnModal(true);
};
const openConnEdit = (c) => {
setEditConn(c);
setConnForm({
client_id: c.client_id, name: c.name || '', from_type: c.from_type || 'network_device',
from_id: c.from_id ?? '', from_port: c.from_port || '', to_type: c.to_type || 'network_device',
to_id: c.to_id ?? '', to_port: c.to_port || '', media_type: c.media_type || 'copper',
speed: c.speed || '1GbE', vlan: c.vlan || '', status: c.status || 'active', notes: c.notes || ''
});
setSaveError(null);
setConnModal(true);
};
const saveConn = async (e) => {
e.preventDefault();
setSaving(true);
setSaveError(null);
const body = {
...connForm,
from_id: connForm.from_id === '' ? null : parseInt(connForm.from_id, 10),
to_id: connForm.to_id === '' ? null : parseInt(connForm.to_id, 10)
};
try {
if (editConn) {
await api.put(`/networking/connections/${editConn.id}`, body);
} else {
await api.post('/networking/connections', body);
}
setConnModal(false);
loadConns();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
const deleteConn = async () => {
if (!editConn) return;
if (!window.confirm('Deze connectie verwijderen?')) return;
setSaving(true);
setSaveError(null);
try {
await api.del(`/networking/connections/${editConn.id}`);
setConnModal(false);
loadConns();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
const endpointLabel = (type, port) =>
[CONN_TYPES[type] || type || '-', port].filter(Boolean).join(' · ');
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight">Netwerken</h1>
<p className="text-[13px] text-muted mt-0.5">Netwerkdevices en connecties</p>
</div>
{tab === 'devices' ? (
<Btn onClick={openDevNew}><Plus className="w-4 h-4" /> Nieuw device</Btn>
) : (
<Btn onClick={openConnNew}><Plus className="w-4 h-4" /> Nieuwe connectie</Btn>
)}
</div>
<TabBar
tabs={[{ key: 'devices', label: 'Devices' }, { key: 'connections', label: 'Connecties' }]}
active={tab}
onChange={setTab}
/>
{tab === 'devices' && (
<Card padding={false}>
<div className="p-4">
{devError ? <ErrorBox error={devError} onRetry={loadDevices} /> : !devices ? <Spinner /> : (
<Table
columns={[
{ label: 'Naam', render: d => <span className="font-medium flex items-center gap-2"><Network className="w-3.5 h-3.5 text-muted" />{d.name}</span> },
{ label: 'Type', render: d => <Badge color="blue">{d.device_type}</Badge> },
{ label: 'Cliënt', key: 'client_name' },
{ label: 'Model', render: d => <span className="text-muted">{d.model || '-'}</span> },
{ label: 'IP', render: d => <span className="text-muted">{d.ip_address || '-'}</span> },
{ label: 'Mgmt IP', render: d => <span className="text-muted">{d.mgmt_ip || '-'}</span> },
{ label: 'Poorten', render: d => d.ports_count ?? '-' },
{ label: 'Status', render: d => <StatusBadge status={d.status} labels={STATUS_LABELS} /> }
]}
rows={devices}
keyFn={d => d.id}
onRowClick={openDevEdit}
empty={<div className="text-center text-muted text-[13px] py-8">Nog geen netwerkdevices</div>}
/>
)}
</div>
</Card>
)}
{tab === 'connections' && (
<Card padding={false}>
<div className="p-4">
{connError ? <ErrorBox error={connError} onRetry={loadConns} /> : !conns ? <Spinner /> : (
<Table
columns={[
{ label: 'Naam', render: c => <span className="font-medium flex items-center gap-2"><Cable className="w-3.5 h-3.5 text-muted" />{c.name || '-'}</span> },
{ label: 'Cliënt', key: 'client_name' },
{ label: 'Van', render: c => endpointLabel(c.from_type, c.from_port) },
{ label: 'Naar', render: c => endpointLabel(c.to_type, c.to_port) },
{ label: 'Media', render: c => <Badge color="purple">{c.media_type || '-'}</Badge> },
{ label: 'Snelheid', render: c => <span className="text-muted">{c.speed || '-'}</span> },
{ label: 'VLAN', render: c => <span className="text-muted">{c.vlan || '-'}</span> },
{ label: 'Status', render: c => <StatusBadge status={c.status} labels={STATUS_LABELS} /> }
]}
rows={conns}
keyFn={c => c.id}
onRowClick={openConnEdit}
empty={<div className="text-center text-muted text-[13px] py-8">Nog geen connecties</div>}
/>
)}
</div>
</Card>
)}
{/* Device-modal */}
<Modal open={devModal} onClose={() => setDevModal(false)} title={editDev ? 'Device bewerken' : 'Nieuw device'} wide>
<form onSubmit={saveDev} className="space-y-3">
{saveError && <ErrorBox error={saveError} />}
<div className="grid grid-cols-2 gap-3">
<Field label="Cliënt *">
<select className={inputCls} value={devForm.client_id} onChange={e => setDevForm({ ...devForm, client_id: e.target.value })} required disabled={!!editDev}>
<option value=""> Kies cliënt </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<Field label="Naam *">
<input className={inputCls} value={devForm.name} onChange={e => setDevForm({ ...devForm, name: e.target.value })} required />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Type *">
<select className={inputCls} value={devForm.device_type} onChange={e => setDevForm({ ...devForm, device_type: e.target.value })} required>
{DEVICE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="Model">
<input className={inputCls} value={devForm.model} onChange={e => setDevForm({ ...devForm, model: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Fabrikant">
<input className={inputCls} value={devForm.manufacturer} onChange={e => setDevForm({ ...devForm, manufacturer: e.target.value })} />
</Field>
<Field label="Aantal poorten">
<input type="number" min="0" className={inputCls} value={devForm.ports_count} onChange={e => setDevForm({ ...devForm, ports_count: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="IP-adres">
<input className={inputCls} value={devForm.ip_address} onChange={e => setDevForm({ ...devForm, ip_address: e.target.value })} />
</Field>
<Field label="Mgmt IP">
<input className={inputCls} value={devForm.mgmt_ip} onChange={e => setDevForm({ ...devForm, mgmt_ip: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="OS-versie">
<input className={inputCls} value={devForm.os_version} onChange={e => setDevForm({ ...devForm, os_version: e.target.value })} />
</Field>
<Field label="Status">
<select className={inputCls} value={devForm.status} onChange={e => setDevForm({ ...devForm, status: e.target.value })}>
<option value="active">Actief</option>
<option value="standby">Standby</option>
<option value="decommissioned">Uitgefaseerd</option>
</select>
</Field>
</div>
<Field label="Notities">
<textarea rows={2} className={inputCls} value={devForm.notes} onChange={e => setDevForm({ ...devForm, notes: e.target.value })} />
</Field>
<div className="flex items-center justify-between gap-2 pt-1">
<div>
{editDev && (
<Btn variant="danger" type="button" onClick={deleteDev} loading={saving}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
)}
</div>
<div className="flex gap-2">
<Btn variant="secondary" type="button" onClick={() => setDevModal(false)}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</div>
</form>
</Modal>
{/* Connectie-modal */}
<Modal open={connModal} onClose={() => setConnModal(false)} title={editConn ? 'Connectie bewerken' : 'Nieuwe connectie'} wide>
<form onSubmit={saveConn} className="space-y-3">
{saveError && <ErrorBox error={saveError} />}
<div className="grid grid-cols-2 gap-3">
<Field label="Cliënt *">
<select className={inputCls} value={connForm.client_id} onChange={e => setConnForm({ ...connForm, client_id: e.target.value })} required disabled={!!editConn}>
<option value=""> Kies cliënt </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<Field label="Naam">
<input className={inputCls} value={connForm.name} onChange={e => setConnForm({ ...connForm, name: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="Van type *">
<select className={inputCls} value={connForm.from_type} onChange={e => setConnForm({ ...connForm, from_type: e.target.value })} required>
{Object.entries(CONN_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Van ID">
<input type="number" min="0" className={inputCls} value={connForm.from_id} onChange={e => setConnForm({ ...connForm, from_id: e.target.value })} />
</Field>
<Field label="Van poort">
<input className={inputCls} placeholder="bijv. Gi0/1" value={connForm.from_port} onChange={e => setConnForm({ ...connForm, from_port: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="Naar type *">
<select className={inputCls} value={connForm.to_type} onChange={e => setConnForm({ ...connForm, to_type: e.target.value })} required>
{Object.entries(CONN_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Naar ID">
<input type="number" min="0" className={inputCls} value={connForm.to_id} onChange={e => setConnForm({ ...connForm, to_id: e.target.value })} />
</Field>
<Field label="Naar poort">
<input className={inputCls} placeholder="bijv. eth0" value={connForm.to_port} onChange={e => setConnForm({ ...connForm, to_port: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="Mediatype">
<select className={inputCls} value={connForm.media_type} onChange={e => setConnForm({ ...connForm, media_type: e.target.value })}>
{MEDIA_TYPES.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</Field>
<Field label="Snelheid">
<input className={inputCls} value={connForm.speed} onChange={e => setConnForm({ ...connForm, speed: e.target.value })} />
</Field>
<Field label="VLAN">
<input className={inputCls} value={connForm.vlan} onChange={e => setConnForm({ ...connForm, vlan: e.target.value })} />
</Field>
</div>
<Field label="Status">
<select className={inputCls} value={connForm.status} onChange={e => setConnForm({ ...connForm, status: e.target.value })}>
<option value="active">Actief</option>
<option value="standby">Standby</option>
<option value="decommissioned">Uitgefaseerd</option>
</select>
</Field>
<Field label="Notities">
<textarea rows={2} className={inputCls} value={connForm.notes} onChange={e => setConnForm({ ...connForm, notes: e.target.value })} />
</Field>
<div className="flex items-center justify-between gap-2 pt-1">
<div>
{editConn && (
<Btn variant="danger" type="button" onClick={deleteConn} loading={saving}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
)}
</div>
<div className="flex gap-2">
<Btn variant="secondary" type="button" onClick={() => setConnModal(false)}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</div>
</form>
</Modal>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import { Bell, CheckCheck } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Badge, Btn, Spinner, EmptyState, ErrorBox } from '../components/ui';
const TYPE_COLORS = {
info: 'blue', warning: 'yellow', error: 'red', success: 'green',
advice: 'purple', reminder: 'accent', system: 'muted'
};
export default function Notifications() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [marking, setMarking] = useState(false);
const load = () => {
setError(null);
api.get('/notifications').then(setData).catch(setError);
};
useEffect(load, []);
const markAll = async () => {
setMarking(true);
try {
await api.post('/notifications/read-all');
load();
} catch (e) {
alert(e.message);
} finally {
setMarking(false);
}
};
const markOne = async (n) => {
if (n.read) return;
try {
await api.post(`/notifications/${n.id}/read`);
load();
} catch (e) {
alert(e.message);
}
};
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!data) return <Spinner />;
const { notifications, unread } = data;
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight">Notificaties</h1>
<p className="text-[13px] text-muted mt-0.5">
{unread > 0 ? `${unread} ongelezen` : 'Alles gelezen'}
</p>
</div>
{unread > 0 && (
<Btn variant="secondary" onClick={markAll} loading={marking}>
<CheckCheck className="w-4 h-4" /> Alles als gelezen markeren
</Btn>
)}
</div>
{notifications.length === 0 ? (
<Card>
<EmptyState icon={Bell} title="Geen notificaties" hint="Je bent helemaal bij." />
</Card>
) : (
<div className="space-y-2">
{notifications.map(n => (
<div
key={n.id}
onClick={() => markOne(n)}
className={`bg-card border border-border-soft rounded-xl px-4 py-3 transition-colors ${
n.read ? '' : 'border-l-2 border-l-accent cursor-pointer hover:bg-card-hover'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className={`text-[13px] ${n.read ? '' : 'font-medium'}`}>{n.title}</div>
{n.message && <div className="text-[12px] text-muted mt-0.5">{n.message}</div>}
</div>
<div className="flex items-center gap-2 shrink-0">
{n.type && <Badge color={TYPE_COLORS[n.type] || 'muted'}>{n.type}</Badge>}
<span className="text-[11px] text-muted whitespace-nowrap">{fmt.datetime(n.created_at)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { useLocation } from 'react-router-dom';
import { Construction } from 'lucide-react';
import { EmptyState } from '../components/ui';
export default function Placeholder() {
const { pathname } = useLocation();
return (
<EmptyState
icon={Construction}
title="Deze module wordt nog gebouwd"
hint={`De route ${pathname} is onderdeel van de nieuwe SPA maar de pagina is nog niet klaar.`}
/>
);
}
+304
View File
@@ -0,0 +1,304 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Trash2, Flag } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Badge, StatusBadge, Btn, Field, inputCls, Modal, Spinner, ErrorBox, STATUS_COLORS } from '../components/ui';
import { ProjectModal, PROJECT_STATUS_LABELS, PRIORITY_LABELS } from './Projects';
const COLUMNS = [
{ key: 'todo', label: 'Te doen' },
{ key: 'in_progress', label: 'Bezig' },
{ key: 'done', label: 'Klaar' }
];
const STATUS_ORDER = ['todo', 'in_progress', 'done'];
const MILESTONE_LABELS = { pending: 'Open', done: 'Behaald', completed: 'Behaald', missed: 'Gemist' };
const EMPTY_TASK = { title: '', description: '', assignee_id: '', priority: 'medium', due_date: '' };
function TaskCard({ task, onMove, onProgress, onDelete }) {
const idx = STATUS_ORDER.indexOf(task.status);
return (
<div className="bg-bg-soft border border-border-soft rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-[13px] font-medium leading-snug">{task.title}</div>
<button
onClick={() => onDelete(task)}
className="p-1 rounded text-muted hover:text-red hover:bg-red/10 shrink-0"
title="Taak verwijderen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge color={STATUS_COLORS[task.priority] || 'muted'}>{PRIORITY_LABELS[task.priority] || task.priority}</Badge>
{task.assignee_name && <span className="text-[11px] text-muted">{task.assignee_name}</span>}
{task.due_date && <span className="text-[11px] text-muted">· {fmt.date(task.due_date)}</span>}
</div>
<div>
<div className="flex items-center justify-between text-[10px] text-muted mb-1">
<span>Voortgang</span>
<span>{task.progress || 0}%</span>
</div>
<input
type="range"
min="0"
max="100"
step="5"
value={task.progress || 0}
onChange={e => onProgress(task, Number(e.target.value))}
className="w-full h-1.5 accent-[#00d4ff] cursor-pointer"
/>
</div>
<div className="flex items-center justify-between pt-1">
<button
disabled={idx <= 0}
onClick={() => onMove(task, STATUS_ORDER[idx - 1])}
className="p-1 rounded text-muted hover:text-text hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed"
title="Naar vorige status"
>
<ChevronLeft className="w-4 h-4" />
</button>
<StatusBadge status={task.status} labels={{ todo: 'Te doen', in_progress: 'Bezig', done: 'Klaar' }} />
<button
disabled={idx >= STATUS_ORDER.length - 1}
onClick={() => onMove(task, STATUS_ORDER[idx + 1])}
className="p-1 rounded text-muted hover:text-text hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed"
title="Naar volgende status"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
}
export default function ProjectDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [clients, setClients] = useState([]);
const [users, setUsers] = useState([]);
const [error, setError] = useState(null);
const [editOpen, setEditOpen] = useState(false);
const [taskOpen, setTaskOpen] = useState(false);
const [taskForm, setTaskForm] = useState(EMPTY_TASK);
const [taskSaving, setTaskSaving] = useState(false);
const [taskError, setTaskError] = useState(null);
const load = () => {
setError(null);
api.get(`/projects/${id}`).then(setData).catch(setError);
};
useEffect(load, [id]);
useEffect(() => {
api.get('/clients').then(setClients).catch(() => {});
api.get('/users').then(setUsers).catch(() => {}); // admin-only: stil negeren
}, []);
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!data) return <Spinner />;
const { project, tasks, milestones } = data;
const doneCount = tasks.filter(t => t.status === 'done').length;
const pct = tasks.length > 0 ? Math.round((doneCount / tasks.length) * 100) : 0;
const moveTask = async (task, status) => {
try {
await api.post(`/projects/tasks/${task.id}/status`, { status });
load();
} catch (e) {
alert(e.message);
}
};
const setProgress = async (task, progress) => {
// optimistisch bijwerken
setData(d => ({ ...d, tasks: d.tasks.map(t => t.id === task.id ? { ...t, progress } : t) }));
try {
await api.post(`/projects/tasks/${task.id}/progress`, { progress });
} catch (e) {
alert(e.message);
load();
}
};
const deleteTask = async (task) => {
if (!window.confirm(`Taak "${task.title}" verwijderen?`)) return;
try {
await api.del(`/projects/tasks/${task.id}`);
load();
} catch (e) {
alert(e.message);
}
};
const deleteProject = async () => {
if (!window.confirm(`Project "${project.name}" en alle bijbehorende taken verwijderen?`)) return;
try {
await api.del(`/projects/${project.id}`);
navigate('/projects');
} catch (e) {
alert(e.message);
}
};
const saveTask = async () => {
if (!taskForm.title.trim()) return setTaskError('Titel is verplicht');
setTaskSaving(true);
setTaskError(null);
try {
await api.post(`/projects/${id}/tasks`, {
...taskForm,
assignee_id: taskForm.assignee_id || null,
due_date: taskForm.due_date || null
});
setTaskOpen(false);
setTaskForm(EMPTY_TASK);
load();
} catch (e) {
setTaskError(e.message);
} finally {
setTaskSaving(false);
}
};
return (
<div className="space-y-5 fade-in">
{/* Header */}
<div>
<Link to="/projects" className="inline-flex items-center gap-1 text-[12px] text-muted hover:text-accent mb-2">
<ArrowLeft className="w-3.5 h-3.5" /> Terug naar projecten
</Link>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight">{project.name}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{project.client_id ? (
<Link to={`/clients/${project.client_id}`} className="text-[13px] text-accent hover:underline">
{project.client_name}
</Link>
) : (
<span className="text-[13px] text-muted">Geen cliënt</span>
)}
<StatusBadge status={project.status} labels={PROJECT_STATUS_LABELS} />
<Badge color={STATUS_COLORS[project.priority] || 'muted'}>{PRIORITY_LABELS[project.priority] || project.priority}</Badge>
<span className="text-[12px] text-muted">{fmt.date(project.start_date)} {fmt.date(project.end_date)}</span>
{project.budget != null && <span className="text-[12px] font-medium">{fmt.euro(project.budget)}</span>}
</div>
{project.description && <p className="text-[13px] text-muted mt-2 max-w-2xl">{project.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Btn variant="secondary" size="sm" onClick={() => setEditOpen(true)}><Pencil className="w-3.5 h-3.5" /> Bewerken</Btn>
<Btn variant="danger" size="sm" onClick={deleteProject}><Trash2 className="w-3.5 h-3.5" /> Verwijderen</Btn>
</div>
</div>
{/* Voortgang */}
<div className="mt-4">
<div className="flex items-center justify-between text-[11px] text-muted mb-1">
<span>Voortgang project</span>
<span>{doneCount}/{tasks.length} taken klaar · {pct}%</span>
</div>
<div className="h-2 rounded-full bg-bg-soft overflow-hidden">
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
</div>
{/* Kanban */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-[15px] font-semibold">Taken</h2>
<Btn size="sm" onClick={() => { setTaskForm(EMPTY_TASK); setTaskError(null); setTaskOpen(true); }}>
<Plus className="w-3.5 h-3.5" /> Taak toevoegen
</Btn>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.status === col.key);
return (
<div key={col.key} className="bg-card border border-border-soft rounded-xl p-3">
<div className="flex items-center justify-between px-1 pb-2">
<span className="text-[12px] font-semibold uppercase tracking-wide text-muted">{col.label}</span>
<span className="text-[11px] text-muted">{colTasks.length}</span>
</div>
<div className="space-y-2">
{colTasks.length === 0 ? (
<div className="text-[12px] text-muted text-center py-6">Geen taken</div>
) : (
colTasks.map(t => (
<TaskCard key={t.id} task={t} onMove={moveTask} onProgress={setProgress} onDelete={deleteTask} />
))
)}
</div>
</div>
);
})}
</div>
</div>
{/* Milestones */}
{milestones.length > 0 && (
<Card title="Milestones" subtitle={`${milestones.length} mijlpaal${milestones.length === 1 ? '' : 'en'}`}>
<div className="space-y-2">
{milestones.map(m => (
<div key={m.id} className="flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover">
<div className="flex items-center gap-2.5 min-w-0">
<Flag className="w-4 h-4 text-accent shrink-0" />
<span className="text-[13px] font-medium truncate">{m.name}</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-[12px] text-muted">{fmt.date(m.target_date)}</span>
<StatusBadge status={m.status} labels={MILESTONE_LABELS} />
</div>
</div>
))}
</div>
</Card>
)}
{/* Project bewerken */}
<ProjectModal
open={editOpen}
onClose={() => setEditOpen(false)}
clients={clients}
project={project}
onSaved={load}
/>
{/* Taak toevoegen */}
<Modal open={taskOpen} onClose={() => setTaskOpen(false)} title="Taak toevoegen">
<div className="space-y-3">
<Field label="Titel *">
<input className={inputCls} value={taskForm.title} onChange={e => setTaskForm(f => ({ ...f, title: e.target.value }))} placeholder="Bijv. inventarisatie uitvoeren" />
</Field>
<Field label="Beschrijving">
<textarea className={inputCls} rows={3} value={taskForm.description} onChange={e => setTaskForm(f => ({ ...f, description: e.target.value }))} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Toegewezen aan">
<select className={inputCls} value={taskForm.assignee_id} onChange={e => setTaskForm(f => ({ ...f, assignee_id: e.target.value }))}>
<option value=""> Niemand </option>
{users.map(u => <option key={u.id} value={u.id}>{u.username}</option>)}
</select>
</Field>
<Field label="Prioriteit">
<select className={inputCls} value={taskForm.priority} onChange={e => setTaskForm(f => ({ ...f, priority: e.target.value }))}>
{Object.entries(PRIORITY_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</Field>
</div>
<Field label="Deadline">
<input type="date" className={inputCls} value={taskForm.due_date} onChange={e => setTaskForm(f => ({ ...f, due_date: e.target.value }))} />
</Field>
</div>
{taskError && <div className="text-red text-[12px] mt-3">{taskError}</div>}
<div className="flex justify-end gap-2 mt-4">
<Btn variant="secondary" onClick={() => setTaskOpen(false)}>Annuleren</Btn>
<Btn onClick={saveTask} loading={taskSaving}>Toevoegen</Btn>
</div>
</Modal>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Plus, FolderKanban } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, StatusBadge, Btn, Field, inputCls, Modal, Spinner, EmptyState, ErrorBox } from '../components/ui';
export const PROJECT_STATUS_LABELS = {
planning: 'Planning', in_progress: 'Bezig', completed: 'Afgerond', on_hold: 'On hold'
};
export const PRIORITY_LABELS = {
low: 'Laag', medium: 'Gemiddeld', high: 'Hoog', critical: 'Kritiek'
};
const EMPTY_FORM = {
name: '', client_id: '', description: '', status: 'planning',
priority: 'medium', budget: '', start_date: '', end_date: ''
};
// Gedeeld formulier — ook gebruikt door ProjectDetail (bewerken)
export function ProjectModal({ open, onClose, clients, project, onSaved }) {
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setError(null);
if (project) {
setForm({
name: project.name || '',
client_id: project.client_id || '',
description: project.description || '',
status: project.status || 'planning',
priority: project.priority || 'medium',
budget: project.budget ?? '',
start_date: project.start_date || '',
end_date: project.end_date || ''
});
} else {
setForm(EMPTY_FORM);
}
}, [open, project]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const save = async () => {
if (!form.name.trim()) return setError('Projectnaam is verplicht');
setSaving(true);
setError(null);
try {
const body = {
...form,
client_id: form.client_id || null,
budget: form.budget === '' ? null : Number(form.budget),
start_date: form.start_date || null,
end_date: form.end_date || null
};
if (project) await api.put(`/projects/${project.id}`, body);
else await api.post('/projects', body);
onClose();
onSaved?.();
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title={project ? 'Project bewerken' : 'Nieuw project'}>
<div className="space-y-3">
<Field label="Naam *">
<input className={inputCls} value={form.name} onChange={e => set('name', e.target.value)} placeholder="Bijv. migratie naar Azure" />
</Field>
<Field label="Cliënt">
<select className={inputCls} value={form.client_id} onChange={e => set('client_id', e.target.value)}>
<option value=""> Geen </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<Field label="Beschrijving">
<textarea className={inputCls} rows={3} value={form.description} onChange={e => set('description', e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Status">
<select className={inputCls} value={form.status} onChange={e => set('status', e.target.value)}>
{Object.entries(PROJECT_STATUS_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</Field>
<Field label="Prioriteit">
<select className={inputCls} value={form.priority} onChange={e => set('priority', e.target.value)}>
{Object.entries(PRIORITY_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="Budget (€)">
<input type="number" min="0" step="0.01" className={inputCls} value={form.budget} onChange={e => set('budget', e.target.value)} />
</Field>
<Field label="Startdatum">
<input type="date" className={inputCls} value={form.start_date} onChange={e => set('start_date', e.target.value)} />
</Field>
<Field label="Einddatum">
<input type="date" className={inputCls} value={form.end_date} onChange={e => set('end_date', e.target.value)} />
</Field>
</div>
</div>
{error && <div className="text-red text-[12px] mt-3">{error}</div>}
<div className="flex justify-end gap-2 mt-4">
<Btn variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn onClick={save} loading={saving}>{project ? 'Opslaan' : 'Aanmaken'}</Btn>
</div>
</Modal>
);
}
export default function Projects() {
const [projects, setProjects] = useState(null);
const [clients, setClients] = useState([]);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const load = () => {
setError(null);
api.get('/projects').then(setProjects).catch(setError);
};
useEffect(load, []);
useEffect(() => { api.get('/clients').then(setClients).catch(() => {}); }, []);
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!projects) return <Spinner />;
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight">Projecten</h1>
<p className="text-[13px] text-muted mt-0.5">{projects.length} project{projects.length === 1 ? '' : 'en'}</p>
</div>
<Btn onClick={() => setModalOpen(true)}><Plus className="w-4 h-4" /> Nieuw project</Btn>
</div>
{projects.length === 0 ? (
<Card>
<EmptyState
icon={FolderKanban}
title="Nog geen projecten"
hint="Maak je eerste project aan om taken en milestones te beheren."
action={<Btn onClick={() => setModalOpen(true)}><Plus className="w-4 h-4" /> Nieuw project</Btn>}
/>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{projects.map(p => {
const pct = p.task_count > 0 ? Math.round((p.done_count / p.task_count) * 100) : 0;
return (
<Card key={p.id} className="card-hover">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link to={`/projects/${p.id}`} className="font-semibold text-[14px] hover:text-accent transition-colors block truncate">
{p.name}
</Link>
<div className="text-[12px] text-muted mt-0.5">{p.client_name || 'Geen cliënt'}</div>
</div>
<StatusBadge status={p.status} labels={PROJECT_STATUS_LABELS} />
</div>
<div className="mt-4">
<div className="flex items-center justify-between text-[11px] text-muted mb-1">
<span>Voortgang</span>
<span>{p.done_count}/{p.task_count} taken · {pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-bg-soft overflow-hidden">
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
<div className="flex items-center justify-between mt-4 text-[12px] text-muted">
<span>{fmt.date(p.start_date)} {fmt.date(p.end_date)}</span>
{p.budget != null && <span className="font-medium text-text">{fmt.euro(p.budget)}</span>}
</div>
</Card>
);
})}
</div>
)}
<ProjectModal
open={modalOpen}
onClose={() => setModalOpen(false)}
clients={clients}
onSaved={load}
/>
</div>
);
}
+358
View File
@@ -0,0 +1,358 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, MapPin, Pencil, Plus, Trash2 } from 'lucide-react';
import { api } from '../api';
import { Card, Badge, StatusBadge, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table } from '../components/ui';
const U_HEIGHT = 28; // px per U
const DEVICE_TYPES = ['server', 'switch', 'router', 'firewall', 'storage', 'ups', 'pdu', 'other'];
const TYPE_COLORS = {
server: 'bg-blue/20 border-blue/60 text-blue',
switch: 'bg-green/20 border-green/60 text-green',
firewall: 'bg-red/20 border-red/60 text-red',
storage: 'bg-purple/20 border-purple/60 text-purple',
ups: 'bg-yellow/20 border-yellow/60 text-yellow'
};
const DEFAULT_TYPE_COLOR = 'bg-accent-soft border-accent/60 text-accent';
const STATUS_LABELS = { active: 'Actief', standby: 'Standby', decommissioned: 'Uitgefaseerd' };
const EMPTY_DEVICE = {
name: '', device_type: 'server', model: '', manufacturer: '', position_u: 1, height_u: 1,
mgmt_ip: '', serial: '', asset_tag: '', status: 'active', specs: '', notes: ''
};
export default function RackDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [rackModal, setRackModal] = useState(false);
const [rackForm, setRackForm] = useState(null);
const [deviceModal, setDeviceModal] = useState(false);
const [editDevice, setEditDevice] = useState(null);
const [deviceForm, setDeviceForm] = useState(EMPTY_DEVICE);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const load = () => {
setError(null);
api.get(`/racks/${id}`).then(setData).catch(setError);
};
useEffect(load, [id]);
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!data) return <Spinner />;
const { rack, devices } = data;
const totalUnits = rack.total_units || 42;
// ---- Rack bewerken / verwijderen ----
const openRackEdit = () => {
setRackForm({
name: rack.name || '', location: rack.location || '', datacenter: rack.datacenter || '',
total_units: totalUnits, notes: rack.notes || ''
});
setSaveError(null);
setRackModal(true);
};
const saveRack = async (e) => {
e.preventDefault();
setSaving(true);
setSaveError(null);
try {
await api.put(`/racks/${id}`, { ...rackForm, total_units: parseInt(rackForm.total_units, 10) || 42 });
setRackModal(false);
load();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
const deleteRack = async () => {
if (!window.confirm(`Rack "${rack.name}" en alle devices verwijderen?`)) return;
try {
await api.del(`/racks/${id}`);
navigate('/racks');
} catch (err) {
alert(err.message || String(err));
}
};
// ---- Devices ----
const openDeviceNew = () => {
setEditDevice(null);
setDeviceForm(EMPTY_DEVICE);
setSaveError(null);
setDeviceModal(true);
};
const openDeviceEdit = (d) => {
setEditDevice(d);
setDeviceForm({
name: d.name || '', device_type: d.device_type || 'server', model: d.model || '',
manufacturer: d.manufacturer || '', position_u: d.position_u || 1, height_u: d.height_u || 1,
mgmt_ip: d.mgmt_ip || '', serial: d.serial || '', asset_tag: d.asset_tag || '',
status: d.status || 'active', specs: d.specs || '', notes: d.notes || ''
});
setSaveError(null);
setDeviceModal(true);
};
const saveDevice = async (e) => {
e.preventDefault();
setSaving(true);
setSaveError(null);
const body = {
...deviceForm,
position_u: parseInt(deviceForm.position_u, 10),
height_u: parseInt(deviceForm.height_u, 10) || 1
};
try {
if (editDevice) {
await api.put(`/racks/devices/${editDevice.id}`, body);
} else {
await api.post(`/racks/${id}/devices`, body);
}
setDeviceModal(false);
load();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
const deleteDevice = async () => {
if (!editDevice) return;
if (!window.confirm(`Device "${editDevice.name}" verwijderen?`)) return;
setSaving(true);
setSaveError(null);
try {
await api.del(`/racks/devices/${editDevice.id}`);
setDeviceModal(false);
load();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
return (
<div className="space-y-5 fade-in">
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div>
<Link to="/racks" className="text-[12px] text-muted hover:text-text flex items-center gap-1 mb-1">
<ArrowLeft className="w-3.5 h-3.5" /> Terug naar racks
</Link>
<h1 className="text-xl font-bold tracking-tight">{rack.name}</h1>
<div className="text-[13px] text-muted mt-0.5 flex items-center gap-2 flex-wrap">
<Link to={`/clients/${rack.client_id}`} className="text-accent hover:underline">{rack.client_name}</Link>
{(rack.location || rack.datacenter) && (
<span className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5" />
{[rack.location, rack.datacenter].filter(Boolean).join(' · ')}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Btn variant="secondary" onClick={openRackEdit}><Pencil className="w-3.5 h-3.5" /> Bewerken</Btn>
<Btn variant="danger" onClick={deleteRack}><Trash2 className="w-3.5 h-3.5" /> Verwijderen</Btn>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 items-start">
{/* Visuele rack-weergave (U1 bovenaan) */}
<Card
title="Rack-overzicht"
subtitle={`${devices.length} devices · ${totalUnits}U`}
actions={<Btn size="sm" onClick={openDeviceNew}><Plus className="w-3.5 h-3.5" /> Device</Btn>}
>
<div className="flex justify-center">
<div className="flex gap-1.5">
{/* U-nummers */}
<div className="flex flex-col text-right" style={{ width: 34 }}>
{Array.from({ length: totalUnits }, (_, i) => (
<div key={i} className="text-[10px] text-muted flex items-center justify-end pr-1" style={{ height: U_HEIGHT }}>
U{i + 1}
</div>
))}
</div>
{/* Rack-frame */}
<div
className="relative border-2 border-border rounded-md bg-bg-soft overflow-hidden"
style={{ width: 260, height: totalUnits * U_HEIGHT }}
>
{/* Lege posities als dunne rijen */}
{Array.from({ length: totalUnits }, (_, i) => (
<div
key={i}
className="absolute left-0 right-0 border-b border-border-soft/40"
style={{ top: i * U_HEIGHT, height: U_HEIGHT }}
/>
))}
{/* Devices */}
{devices.map(d => {
const pos = Math.max(1, Math.min(d.position_u || 1, totalUnits));
const h = Math.max(1, d.height_u || 1);
const colorCls = TYPE_COLORS[d.device_type] || DEFAULT_TYPE_COLOR;
return (
<button
key={d.id}
onClick={() => openDeviceEdit(d)}
title={`${d.name}${d.model ? `${d.model}` : ''} (U${pos}${h > 1 ? `U${pos + h - 1}` : ''})`}
className={`absolute left-1 right-1 rounded border px-2 flex flex-col justify-center overflow-hidden text-left hover:brightness-125 transition-all cursor-pointer ${colorCls}`}
style={{ top: (pos - 1) * U_HEIGHT + 1, height: h * U_HEIGHT - 2 }}
>
<span className="text-[11px] font-semibold truncate leading-tight">{d.name}</span>
{d.model && h * U_HEIGHT >= 36 && (
<span className="text-[10px] opacity-75 truncate leading-tight">{d.model}</span>
)}
</button>
);
})}
</div>
</div>
</div>
{/* Legenda */}
<div className="flex items-center gap-3 flex-wrap mt-4 pt-3 border-t border-border-soft">
{Object.keys(TYPE_COLORS).map(t => (
<span key={t} className="flex items-center gap-1.5 text-[11px] text-muted">
<span className={`w-2.5 h-2.5 rounded-sm border ${TYPE_COLORS[t]}`} /> {t}
</span>
))}
<span className="flex items-center gap-1.5 text-[11px] text-muted">
<span className={`w-2.5 h-2.5 rounded-sm border ${DEFAULT_TYPE_COLOR}`} /> overig
</span>
</div>
</Card>
{/* Device-tabel */}
<Card title="Devices" subtitle={`${devices.length} in dit rack`}>
<Table
columns={[
{ label: 'Naam', render: d => <span className="text-accent font-medium">{d.name}</span> },
{ label: 'Type', render: d => <Badge color="blue">{d.device_type}</Badge> },
{ label: 'Model', render: d => <span className="text-muted">{d.model || '-'}</span> },
{ label: 'Positie', render: d => `U${d.position_u}` },
{ label: 'Mgmt IP', render: d => <span className="text-muted">{d.mgmt_ip || '-'}</span> },
{ label: 'Status', render: d => <StatusBadge status={d.status} labels={STATUS_LABELS} /> }
]}
rows={devices}
keyFn={d => d.id}
onRowClick={openDeviceEdit}
empty={<div className="text-center text-muted text-[13px] py-8">Nog geen devices in dit rack</div>}
/>
</Card>
</div>
{/* Rack bewerk-modal */}
<Modal open={rackModal} onClose={() => setRackModal(false)} title="Rack bewerken">
{rackForm && (
<form onSubmit={saveRack} className="space-y-3">
{saveError && <ErrorBox error={saveError} />}
<Field label="Naam *">
<input className={inputCls} value={rackForm.name} onChange={e => setRackForm({ ...rackForm, name: e.target.value })} required />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Locatie">
<input className={inputCls} value={rackForm.location} onChange={e => setRackForm({ ...rackForm, location: e.target.value })} />
</Field>
<Field label="Datacenter">
<input className={inputCls} value={rackForm.datacenter} onChange={e => setRackForm({ ...rackForm, datacenter: e.target.value })} />
</Field>
</div>
<Field label="Aantal U">
<input type="number" min="1" className={inputCls} value={rackForm.total_units} onChange={e => setRackForm({ ...rackForm, total_units: e.target.value })} />
</Field>
<Field label="Notities">
<textarea rows={3} className={inputCls} value={rackForm.notes} onChange={e => setRackForm({ ...rackForm, notes: e.target.value })} />
</Field>
<div className="flex justify-end gap-2 pt-1">
<Btn variant="secondary" type="button" onClick={() => setRackModal(false)}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</form>
)}
</Modal>
{/* Device-modal */}
<Modal open={deviceModal} onClose={() => setDeviceModal(false)} title={editDevice ? 'Device bewerken' : 'Device toevoegen'} wide>
<form onSubmit={saveDevice} className="space-y-3">
{saveError && <ErrorBox error={saveError} />}
<div className="grid grid-cols-2 gap-3">
<Field label="Naam *">
<input className={inputCls} value={deviceForm.name} onChange={e => setDeviceForm({ ...deviceForm, name: e.target.value })} required />
</Field>
<Field label="Type">
<select className={inputCls} value={deviceForm.device_type} onChange={e => setDeviceForm({ ...deviceForm, device_type: e.target.value })}>
{DEVICE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Model">
<input className={inputCls} value={deviceForm.model} onChange={e => setDeviceForm({ ...deviceForm, model: e.target.value })} />
</Field>
<Field label="Fabrikant">
<input className={inputCls} value={deviceForm.manufacturer} onChange={e => setDeviceForm({ ...deviceForm, manufacturer: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label={`Positie U * (1${totalUnits})`}>
<input type="number" min="1" max={totalUnits} className={inputCls} value={deviceForm.position_u} onChange={e => setDeviceForm({ ...deviceForm, position_u: e.target.value })} required />
</Field>
<Field label="Hoogte (U)">
<input type="number" min="1" max={totalUnits} className={inputCls} value={deviceForm.height_u} onChange={e => setDeviceForm({ ...deviceForm, height_u: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="Mgmt IP">
<input className={inputCls} value={deviceForm.mgmt_ip} onChange={e => setDeviceForm({ ...deviceForm, mgmt_ip: e.target.value })} />
</Field>
<Field label="Serienummer">
<input className={inputCls} value={deviceForm.serial} onChange={e => setDeviceForm({ ...deviceForm, serial: e.target.value })} />
</Field>
<Field label="Asset-tag">
<input className={inputCls} value={deviceForm.asset_tag} onChange={e => setDeviceForm({ ...deviceForm, asset_tag: e.target.value })} />
</Field>
</div>
<Field label="Status">
<select className={inputCls} value={deviceForm.status} onChange={e => setDeviceForm({ ...deviceForm, status: e.target.value })}>
<option value="active">Actief</option>
<option value="standby">Standby</option>
<option value="decommissioned">Uitgefaseerd</option>
</select>
</Field>
<Field label="Specs">
<textarea rows={2} className={inputCls} value={deviceForm.specs} onChange={e => setDeviceForm({ ...deviceForm, specs: e.target.value })} />
</Field>
<Field label="Notities">
<textarea rows={2} className={inputCls} value={deviceForm.notes} onChange={e => setDeviceForm({ ...deviceForm, notes: e.target.value })} />
</Field>
<div className="flex items-center justify-between gap-2 pt-1">
<div>
{editDevice && (
<Btn variant="danger" type="button" onClick={deleteDevice} loading={saving}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
)}
</div>
<div className="flex gap-2">
<Btn variant="secondary" type="button" onClick={() => setDeviceModal(false)}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</div>
</form>
</Modal>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Plus, MapPin, Server } from 'lucide-react';
import { api } from '../api';
import { Card, Btn, Field, inputCls, Modal, Spinner, EmptyState, ErrorBox } from '../components/ui';
const EMPTY_FORM = { client_id: '', name: '', location: '', datacenter: '', total_units: 42, notes: '' };
export default function Racks() {
const [racks, setRacks] = useState(null);
const [error, setError] = useState(null);
const [clients, setClients] = useState([]);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const load = () => {
setError(null);
api.get('/racks').then(setRacks).catch(setError);
};
useEffect(load, []);
useEffect(() => {
api.get('/clients').then(setClients).catch(() => {});
}, []);
const openNew = () => {
setForm(EMPTY_FORM);
setSaveError(null);
setModalOpen(true);
};
const save = async (e) => {
e.preventDefault();
setSaving(true);
setSaveError(null);
try {
await api.post('/racks', { ...form, total_units: parseInt(form.total_units, 10) || 42 });
setModalOpen(false);
load();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!racks) return <Spinner />;
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight">Racks</h1>
<p className="text-[13px] text-muted mt-0.5">Rack-inventaris per cliënt</p>
</div>
<Btn onClick={openNew}><Plus className="w-4 h-4" /> Nieuw rack</Btn>
</div>
{racks.length === 0 ? (
<Card>
<EmptyState
icon={Server}
title="Nog geen racks"
hint="Maak je eerste rack aan om devices toe te voegen."
action={<Btn onClick={openNew}><Plus className="w-4 h-4" /> Nieuw rack</Btn>}
/>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{racks.map(r => {
const total = r.total_units || 42;
const used = r.device_count || 0;
const pct = Math.min(100, Math.round((used / total) * 100));
return (
<div key={r.id} className="bg-card border border-border-soft rounded-xl p-4 card-hover">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link to={`/racks/${r.id}`} className="text-[14px] font-semibold text-accent hover:underline truncate block">
{r.name}
</Link>
<div className="text-[12px] text-muted mt-0.5">{r.client_name}</div>
</div>
<div className="w-9 h-9 rounded-lg bg-accent-soft flex items-center justify-center shrink-0">
<Server className="w-[18px] h-[18px] text-accent" />
</div>
</div>
{(r.location || r.datacenter) && (
<div className="flex items-center gap-1.5 text-[12px] text-muted mt-2">
<MapPin className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{[r.location, r.datacenter].filter(Boolean).join(' · ')}</span>
</div>
)}
<div className="mt-3">
<div className="flex items-center justify-between text-[12px] mb-1">
<span className="text-muted">Bezetting</span>
<span>{used} / {total}U bezet</span>
</div>
<div className="h-1.5 bg-bg-soft rounded-full overflow-hidden">
<div className="h-full bg-accent rounded-full" style={{ width: `${pct}%` }} />
</div>
</div>
</div>
);
})}
</div>
)}
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title="Nieuw rack">
<form onSubmit={save} className="space-y-3">
{saveError && <ErrorBox error={saveError} />}
<Field label="Cliënt *">
<select className={inputCls} value={form.client_id} onChange={e => setForm({ ...form, client_id: e.target.value })} required>
<option value=""> Kies cliënt </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<Field label="Naam *">
<input className={inputCls} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Locatie">
<input className={inputCls} value={form.location} onChange={e => setForm({ ...form, location: e.target.value })} />
</Field>
<Field label="Datacenter">
<input className={inputCls} value={form.datacenter} onChange={e => setForm({ ...form, datacenter: e.target.value })} />
</Field>
</div>
<Field label="Aantal U">
<input type="number" min="1" className={inputCls} value={form.total_units} onChange={e => setForm({ ...form, total_units: e.target.value })} />
</Field>
<Field label="Notities">
<textarea rows={3} className={inputCls} value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</Field>
<div className="flex justify-end gap-2 pt-1">
<Btn variant="secondary" type="button" onClick={() => setModalOpen(false)}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</form>
</Modal>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { SearchX } from 'lucide-react';
import { api, qs, fmt } from '../api';
import { Card, StatusBadge, Badge, Spinner, EmptyState, ErrorBox } from '../components/ui';
const CLIENT_STATUS_LABELS = { lead: 'Lead', active: 'Actief', paused: 'Gepauzeerd', archived: 'Gearchiveerd' };
const TASK_STATUS_LABELS = { todo: 'Te doen', in_progress: 'Bezig', done: 'Klaar', cancelled: 'Geannuleerd' };
function ResultRow({ to, children }) {
const inner = (
<div className="flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
{children}
</div>
);
return to ? <Link to={to} className="block">{inner}</Link> : inner;
}
export default function Search() {
const [params] = useSearchParams();
const q = (params.get('q') || '').trim();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
setData(null);
setError(null);
if (!q) {
setData({ clients: [], engagements: [], tasks: [], diagrams: [], notes: [] });
return;
}
api.get(`/search${qs({ q })}`).then(setData).catch(setError);
}, [q]);
if (error) return <ErrorBox error={error} onRetry={() => api.get(`/search${qs({ q })}`).then(setData).catch(setError)} />;
if (!data) return <Spinner text={`Zoeken naar "${q}"...`} />;
const { clients, engagements, tasks, diagrams, notes } = data;
const total = clients.length + engagements.length + tasks.length + diagrams.length + notes.length;
return (
<div className="space-y-5 fade-in">
<div>
<h1 className="text-xl font-bold tracking-tight">Zoekresultaten voor &ldquo;{q}&rdquo;</h1>
<p className="text-[13px] text-muted mt-0.5">{total} resultaat{total === 1 ? '' : 'en'} gevonden</p>
</div>
{total === 0 ? (
<Card>
<EmptyState
icon={SearchX}
title="Geen resultaten"
hint={`Er is niets gevonden voor "${q}". Probeer een andere zoekterm.`}
/>
</Card>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{clients.length > 0 && (
<Card title="Cliënten" subtitle={`${clients.length} gevonden`}>
<div className="space-y-1">
{clients.map(c => (
<ResultRow key={c.id} to={`/clients/${c.id}`}>
<div className="min-w-0">
<div className="text-[13px] font-medium truncate">{c.name}</div>
{c.industry && <div className="text-[11px] text-muted">{c.industry}</div>}
</div>
<StatusBadge status={c.status} labels={CLIENT_STATUS_LABELS} />
</ResultRow>
))}
</div>
</Card>
)}
{engagements.length > 0 && (
<Card title="Engagements" subtitle={`${engagements.length} gevonden`}>
<div className="space-y-1">
{engagements.map(e => (
<ResultRow key={e.id} to={`/engagements/${e.id}`}>
<div className="min-w-0">
<div className="text-[13px] font-medium truncate">{e.title}</div>
<div className="text-[11px] text-muted">{e.client_name}</div>
</div>
<StatusBadge status={e.status} labels={{ planned: 'Gepland', in_progress: 'Lopend', completed: 'Afgerond', on_hold: 'On hold' }} />
</ResultRow>
))}
</div>
</Card>
)}
{tasks.length > 0 && (
<Card title="Taken" subtitle={`${tasks.length} gevonden`}>
<div className="space-y-1">
{tasks.map(t => (
<ResultRow key={t.id}>
<div className="min-w-0">
<div className="text-[13px] font-medium truncate">{t.title}</div>
<div className="text-[11px] text-muted">{t.client_name}{t.engagement_title ? ` · ${t.engagement_title}` : ''}</div>
</div>
<StatusBadge status={t.status} labels={TASK_STATUS_LABELS} />
</ResultRow>
))}
</div>
</Card>
)}
{diagrams.length > 0 && (
<Card title="Diagrammen" subtitle={`${diagrams.length} gevonden`}>
<div className="space-y-1">
{diagrams.map(d => (
<ResultRow key={d.id} to="/diagrams">
<div className="min-w-0">
<div className="text-[13px] font-medium truncate">{d.name}</div>
<div className="text-[11px] text-muted">{d.client_name}</div>
</div>
<Badge color="purple">{d.diagram_type}</Badge>
</ResultRow>
))}
</div>
</Card>
)}
{notes.length > 0 && (
<Card title="Notities" subtitle={`${notes.length} gevonden`}>
<div className="space-y-1">
{notes.map(n => (
<ResultRow key={n.id} to={`/clients/${n.client_id}`}>
<div className="min-w-0">
<div className="text-[13px] truncate">
{n.content.length > 120 ? `${n.content.slice(0, 120)}` : n.content}
</div>
<div className="text-[11px] text-muted">{n.client_name} · {fmt.date(n.created_at)}</div>
</div>
</ResultRow>
))}
</div>
</Card>
)}
</div>
)}
</div>
);
}
+404
View File
@@ -0,0 +1,404 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Plus, Trash2, DatabaseBackup, Download } from 'lucide-react';
import { api, fmt } from '../api';
import { Card, Badge, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table } from '../components/ui';
const TABS = [
{ key: 'algemeen', label: 'Algemeen' },
{ key: 'gebruikers', label: 'Gebruikers' },
{ key: 'backups', label: 'Backups' },
{ key: 'audit', label: 'Audit-log' }
];
const ROLE_COLORS = { admin: 'accent', consultant: 'blue', viewer: 'muted' };
const ROLE_LABELS = { admin: 'Admin', consultant: 'Consultant', viewer: 'Viewer' };
const EMPTY_USER = { username: '', password: '', email: '', role: 'viewer' };
function Banner({ banner }) {
if (!banner) return null;
return (
<div className={`rounded-lg px-4 py-3 text-[13px] border ${
banner.type === 'green' ? 'bg-green/10 border-green/30 text-green' : 'bg-red/10 border-red/30 text-red'
}`}>
{banner.text}
</div>
);
}
// ============ Tab: Algemeen ============
function GeneralTab() {
const [form, setForm] = useState(null);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const [banner, setBanner] = useState(null);
const load = () => {
setError(null);
api.get('/settings').then(setForm).catch(setError);
};
useEffect(load, []);
const save = async (e) => {
e.preventDefault();
setSaving(true);
setBanner(null);
try {
await api.post('/settings', form);
setBanner({ type: 'green', text: 'Instellingen opgeslagen' });
} catch (err) {
setBanner({ type: 'red', text: err.message || String(err) });
} finally {
setSaving(false);
}
};
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!form) return <Spinner />;
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });
return (
<Card title="Algemene instellingen">
<form onSubmit={save} className="space-y-3 max-w-2xl">
<Banner banner={banner} />
<div className="grid grid-cols-2 gap-3">
<Field label="Merknaam">
<input className={inputCls} value={form.brand_name || ''} onChange={set('brand_name')} />
</Field>
<Field label="Taal">
<select className={inputCls} value={form.language || 'nl'} onChange={set('language')}>
<option value="nl">Nederlands</option>
<option value="en">English</option>
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Thema">
<input className={inputCls} value={form.theme || ''} onChange={set('theme')} />
</Field>
<Field label="Showcase URL">
<input className={inputCls} value={form.showcase_url || ''} onChange={set('showcase_url')} />
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="Standaard pagina">
<input className={inputCls} value={form.default_page || ''} onChange={set('default_page')} />
</Field>
<Field label="Auto-refresh">
<select className={inputCls} value={String(form.auto_refresh ?? '0')} onChange={set('auto_refresh')}>
<option value="0">Uit</option>
<option value="1">Aan</option>
</select>
</Field>
<Field label="Items per pagina">
<input type="number" min="1" className={inputCls} value={form.items_per_page || ''} onChange={set('items_per_page')} />
</Field>
</div>
<div className="text-[12px] text-muted font-medium pt-1">SMTP (uitgaande e-mail)</div>
<div className="grid grid-cols-3 gap-3">
<Field label="SMTP host" className="col-span-2">
<input className={inputCls} value={form.smtp_host || ''} onChange={set('smtp_host')} />
</Field>
<Field label="SMTP poort">
<input className={inputCls} value={form.smtp_port || ''} onChange={set('smtp_port')} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="SMTP gebruiker">
<input className={inputCls} value={form.smtp_user || ''} onChange={set('smtp_user')} />
</Field>
<Field label="SMTP wachtwoord">
<input type="password" className={inputCls} value={form.smtp_pass || ''} onChange={set('smtp_pass')} placeholder="••••••••" />
</Field>
</div>
<Field label="Slack webhook">
<input className={inputCls} value={form.slack_webhook || ''} onChange={set('slack_webhook')} />
</Field>
<div className="pt-1">
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</form>
</Card>
);
}
// ============ Tab: Gebruikers ============
function UsersTab() {
const [users, setUsers] = useState(null);
const [error, setError] = useState(null);
const [modal, setModal] = useState(false);
const [editUser, setEditUser] = useState(null);
const [form, setForm] = useState(EMPTY_USER);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const load = () => {
setError(null);
api.get('/users').then(setUsers).catch(setError);
};
useEffect(load, []);
const openNew = () => {
setEditUser(null);
setForm(EMPTY_USER);
setSaveError(null);
setModal(true);
};
const openEdit = (u) => {
setEditUser(u);
setForm({ username: u.username, password: '', email: u.email || '', role: u.role || 'viewer' });
setSaveError(null);
setModal(true);
};
const save = async (e) => {
e.preventDefault();
if (!editUser && form.password.length < 6) {
setSaveError(new Error('Wachtwoord moet minimaal 6 tekens zijn'));
return;
}
setSaving(true);
setSaveError(null);
try {
if (editUser) {
await api.put(`/users/${editUser.id}`, { email: form.email, role: form.role, password: form.password || undefined });
} else {
await api.post('/users', form);
}
setModal(false);
load();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!editUser) return;
if (!window.confirm(`Gebruiker "${editUser.username}" verwijderen?`)) return;
setSaving(true);
setSaveError(null);
try {
await api.del(`/users/${editUser.id}`);
setModal(false);
load();
} catch (err) {
setSaveError(err);
} finally {
setSaving(false);
}
};
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!users) return <Spinner />;
return (
<Card
title="Gebruikers"
actions={<Btn size="sm" onClick={openNew}><Plus className="w-3.5 h-3.5" /> Nieuwe gebruiker</Btn>}
>
<Table
columns={[
{ label: 'Username', render: u => <span className="font-medium">{u.username}</span> },
{ label: 'Email', render: u => <span className="text-muted">{u.email || '-'}</span> },
{ label: 'Rol', render: u => <Badge color={ROLE_COLORS[u.role] || 'muted'}>{ROLE_LABELS[u.role] || u.role}</Badge> },
{ label: 'Aangemaakt', render: u => <span className="text-muted">{fmt.date(u.created_at)}</span> }
]}
rows={users}
keyFn={u => u.id}
onRowClick={openEdit}
/>
<Modal open={modal} onClose={() => setModal(false)} title={editUser ? `Gebruiker "${editUser.username}" bewerken` : 'Nieuwe gebruiker'}>
<form onSubmit={save} className="space-y-3">
{saveError && <ErrorBox error={saveError} />}
{!editUser && (
<Field label="Gebruikersnaam *">
<input className={inputCls} value={form.username} onChange={e => setForm({ ...form, username: e.target.value })} required />
</Field>
)}
<Field label={editUser ? 'Nieuw wachtwoord (leeg = behouden)' : 'Wachtwoord * (min. 6 tekens)'}>
<input
type="password"
className={inputCls}
value={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
required={!editUser}
minLength={editUser ? undefined : 6}
/>
</Field>
<Field label="Email">
<input type="email" className={inputCls} value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} />
</Field>
<Field label="Rol">
<select className={inputCls} value={form.role} onChange={e => setForm({ ...form, role: e.target.value })}>
<option value="viewer">Viewer</option>
<option value="consultant">Consultant</option>
<option value="admin">Admin</option>
</select>
</Field>
<div className="flex items-center justify-between gap-2 pt-1">
<div>
{editUser && (
<Btn variant="danger" type="button" onClick={remove} loading={saving}>
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
</Btn>
)}
</div>
<div className="flex gap-2">
<Btn variant="secondary" type="button" onClick={() => setModal(false)}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Opslaan</Btn>
</div>
</div>
</form>
</Modal>
</Card>
);
}
// ============ Tab: Backups ============
function BackupsTab() {
const [backups, setBackups] = useState(null);
const [error, setError] = useState(null);
const [creating, setCreating] = useState(false);
const [banner, setBanner] = useState(null);
const load = () => {
setError(null);
api.get('/backup').then(setBackups).catch(setError);
};
useEffect(load, []);
const create = async () => {
setCreating(true);
setBanner(null);
try {
const res = await api.post('/backup/create');
setBanner({ type: 'green', text: `Backup gemaakt: ${res.filename}` });
load();
} catch (err) {
setBanner({ type: 'red', text: err.message || String(err) });
} finally {
setCreating(false);
}
};
const remove = async (b) => {
if (!window.confirm(`Backup "${b.filename}" verwijderen?`)) return;
setBanner(null);
try {
await api.post(`/backup/delete/${encodeURIComponent(b.filename)}`);
load();
} catch (err) {
setBanner({ type: 'red', text: err.message || String(err) });
}
};
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!backups) return <Spinner />;
return (
<Card
title="Backups"
subtitle="Database- en bestandsbackups"
actions={<Btn size="sm" onClick={create} loading={creating}><DatabaseBackup className="w-3.5 h-3.5" /> Backup maken</Btn>}
>
<div className="space-y-3">
<Banner banner={banner} />
<Table
columns={[
{ label: 'Bestandsnaam', render: b => <span className="font-medium flex items-center gap-2"><Download className="w-3.5 h-3.5 text-muted" />{b.filename}</span> },
{ label: 'Grootte', render: b => <span className="text-muted">{b.sizeLabel || b.size || '-'}</span> },
{ label: 'Aangemaakt', render: b => <span className="text-muted">{fmt.datetime(b.created_at)}</span> },
{
label: '', className: 'text-right',
render: b => (
<button onClick={() => remove(b)} className="p-1.5 rounded-md text-muted hover:text-red hover:bg-red/10" title="Verwijderen">
<Trash2 className="w-3.5 h-3.5" />
</button>
)
}
]}
rows={backups}
keyFn={b => b.filename}
empty={<div className="text-center text-muted text-[13px] py-8">Nog geen backups</div>}
/>
</div>
</Card>
);
}
// ============ Tab: Audit-log ============
function AuditTab() {
const [page, setPage] = useState(1);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const load = () => {
setError(null);
api.get(`/audit?page=${page}`).then(setData).catch(setError);
};
useEffect(load, [page]);
if (error) return <ErrorBox error={error} onRetry={load} />;
if (!data) return <Spinner />;
return (
<Card title="Audit-log" subtitle={`${data.total} gebeurtenissen`}>
<Table
columns={[
{ label: 'Wanneer', render: r => <span className="text-muted whitespace-nowrap">{fmt.datetime(r.created_at)}</span> },
{ label: 'Gebruiker', render: r => r.username || '-' },
{ label: 'Actie', render: r => <Badge color="blue">{r.action}</Badge> },
{ label: 'Entiteit', render: r => <span className="text-muted">{[r.entity_type, r.entity_id].filter(v => v !== null && v !== undefined && v !== '').join(' #') || '-'}</span> },
{ label: 'Details', render: r => <span className="text-muted block max-w-sm truncate">{r.details || '-'}</span> }
]}
rows={data.entries}
keyFn={r => r.id}
empty={<div className="text-center text-muted text-[13px] py-8">Nog geen activiteit</div>}
/>
<div className="flex items-center justify-between pt-3 mt-1 border-t border-border-soft">
<Btn variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Vorige</Btn>
<span className="text-[12px] text-muted">Pagina {data.page} van {data.totalPages || 1}</span>
<Btn variant="secondary" size="sm" disabled={page >= (data.totalPages || 1)} onClick={() => setPage(p => p + 1)}>Volgende</Btn>
</div>
</Card>
);
}
// ============ Settings-pagina ============
export default function Settings() {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'algemeen';
const setTab = (key) => setSearchParams({ tab: key });
return (
<div className="space-y-5 fade-in">
<div>
<h1 className="text-xl font-bold tracking-tight">Instellingen</h1>
<p className="text-[13px] text-muted mt-0.5">Applicatie-instellingen, gebruikers, backups en audit-log</p>
</div>
<div className="flex gap-1 border-b border-border-soft">
{TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-3.5 py-2 text-[13px] font-medium border-b-2 -mb-px transition-colors ${
tab === t.key ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-text'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'algemeen' && <GeneralTab />}
{tab === 'gebruikers' && <UsersTab />}
{tab === 'backups' && <BackupsTab />}
{tab === 'audit' && <AuditTab />}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { Plus, ArrowLeft, ArrowRight, Trash2, CheckSquare } from 'lucide-react';
import { api, fmt } from '../api';
import { Btn, Field, inputCls, Modal, Spinner, ErrorBox, StatusBadge, Badge, EmptyState } from '../components/ui';
export const TASK_STATUS_LABELS = { todo: 'Te doen', in_progress: 'Bezig', done: 'Klaar', cancelled: 'Geannuleerd' };
export const PRIORITY_LABELS = { critical: 'Kritiek', high: 'Hoog', medium: 'Gemiddeld', low: 'Laag' };
const COLUMNS = ['todo', 'in_progress', 'done', 'cancelled'];
const FLOW = ['todo', 'in_progress', 'done'];
const EMPTY_FORM = { engagement_id: '', title: '', priority: 'medium', due_date: '' };
function TaskFormModal({ open, onClose, engagements, onSaved }) {
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setError(null);
setForm(EMPTY_FORM);
}, [open]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.post('/tasks', { ...form, due_date: form.due_date || null });
onSaved();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Nieuwe taak">
<form onSubmit={submit} className="space-y-3">
<ErrorBox error={error} />
<Field label="Engagement *">
<select className={inputCls} value={form.engagement_id} onChange={set('engagement_id')} required>
<option value=""> Kies een engagement </option>
{engagements.map((e) => (
<option key={e.id} value={e.id}>{e.title} {e.client_name}</option>
))}
</select>
</Field>
<Field label="Titel *">
<input className={inputCls} value={form.title} onChange={set('title')} required autoFocus />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Prioriteit">
<select className={inputCls} value={form.priority} onChange={set('priority')}>
{Object.entries(PRIORITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Deadline">
<input type="date" className={inputCls} value={form.due_date} onChange={set('due_date')} />
</Field>
</div>
<div className="flex justify-end gap-2 pt-1">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Aanmaken</Btn>
</div>
</form>
</Modal>
);
}
function TaskCard({ task, onMove, onDelete }) {
const today = new Date().toISOString().slice(0, 10);
const overdue = task.due_date && task.due_date < today && task.status !== 'done' && task.status !== 'cancelled';
const idx = FLOW.indexOf(task.status);
return (
<div className="bg-card border border-border-soft rounded-lg p-2.5 space-y-1.5">
<div className="text-[13px] font-medium">{task.title}</div>
<div className="text-[11px] text-muted truncate">{task.client_name} · {task.engagement_title}</div>
<div className="flex items-center justify-between gap-2">
<StatusBadge status={task.priority} labels={PRIORITY_LABELS} />
{task.due_date && (
<span className={`text-[11px] ${overdue ? 'text-red font-medium' : 'text-muted'}`}>
{fmt.date(task.due_date)}
</span>
)}
</div>
<div className="flex items-center justify-between pt-1.5 border-t border-border-soft/50">
<div className="flex gap-0.5">
{idx > 0 && (
<button
onClick={() => onMove(task, FLOW[idx - 1])}
className="p-1 rounded text-muted hover:text-text hover:bg-bg-soft"
title={`Naar '${TASK_STATUS_LABELS[FLOW[idx - 1]]}'`}
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
)}
{idx >= 0 && idx < FLOW.length - 1 && (
<button
onClick={() => onMove(task, FLOW[idx + 1])}
className="p-1 rounded text-muted hover:text-text hover:bg-bg-soft"
title={`Naar '${TASK_STATUS_LABELS[FLOW[idx + 1]]}'`}
>
<ArrowRight className="w-3.5 h-3.5" />
</button>
)}
</div>
<button
onClick={() => onDelete(task)}
className="p-1 rounded text-muted hover:text-red hover:bg-bg-soft"
title="Verwijderen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
export default function Tasks() {
const [data, setData] = useState(null);
const [engagements, setEngagements] = useState([]);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const load = () => {
setError(null);
api.get('/tasks').then(setData).catch(setError);
};
useEffect(load, []);
useEffect(() => {
api.get('/engagements').then(setEngagements).catch(() => { /* opties voor modal */ });
}, []);
const move = async (task, status) => {
try {
await api.post(`/tasks/${task.id}/status`, { status });
load();
} catch (err) {
setError(err);
}
};
const remove = async (task) => {
if (!window.confirm(`Taak "${task.title}" verwijderen?`)) return;
try {
await api.del(`/tasks/${task.id}`);
load();
} catch (err) {
setError(err);
}
};
return (
<div className="space-y-4 fade-in">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-xl font-bold tracking-tight">Taken</h1>
<p className="text-[13px] text-muted mt-0.5">Kanban-overzicht van alle taken</p>
</div>
<Btn onClick={() => setModalOpen(true)}>
<Plus className="w-4 h-4" /> Nieuwe taak
</Btn>
</div>
{error && <ErrorBox error={error} onRetry={load} />}
{!data && !error && <Spinner />}
{data && data.length === 0 && (
<EmptyState
icon={CheckSquare}
title="Nog geen taken"
hint="Maak je eerste taak aan om te beginnen."
action={<Btn onClick={() => setModalOpen(true)}><Plus className="w-4 h-4" /> Nieuwe taak</Btn>}
/>
)}
{data && data.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3 items-start">
{COLUMNS.map((col) => {
const colTasks = data.filter((t) => t.status === col);
return (
<div key={col} className="bg-bg-soft border border-border-soft rounded-xl p-2.5">
<div className="flex items-center justify-between px-1 pb-2">
<span className="text-[12px] font-semibold">{TASK_STATUS_LABELS[col]}</span>
<Badge color="muted">{colTasks.length}</Badge>
</div>
<div className="space-y-2">
{colTasks.map((t) => (
<TaskCard key={t.id} task={t} onMove={move} onDelete={remove} />
))}
{colTasks.length === 0 && (
<div className="text-center text-[11px] text-muted py-4">Geen taken</div>
)}
</div>
</div>
);
})}
</div>
)}
<TaskFormModal
open={modalOpen}
onClose={() => setModalOpen(false)}
engagements={engagements}
onSaved={() => { setModalOpen(false); load(); }}
/>
</div>
);
}
+307
View File
@@ -0,0 +1,307 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Clock, Plus, FileText, Pencil, Trash2, Euro } from 'lucide-react';
import { api, qs, fmt } from '../api';
import { Card, KpiCard, Badge, Btn, Field, inputCls, Modal, Spinner, EmptyState, ErrorBox, Table } from '../components/ui';
const today = () => new Date().toISOString().split('T')[0];
const EMPTY_ENTRY = {
client_id: '', engagement_id: '', date: '', hours: '',
description: '', billable: true, hourly_rate: 150
};
function EntryModal({ open, onClose, entry, clients, engagements, onSaved }) {
const [form, setForm] = useState(EMPTY_ENTRY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setError(null);
setForm(entry ? {
client_id: entry.client_id || '',
engagement_id: entry.engagement_id || '',
date: entry.date || today(),
hours: entry.hours ?? '',
description: entry.description || '',
billable: !!entry.billable,
hourly_rate: entry.hourly_rate ?? 150
} : { ...EMPTY_ENTRY, date: today() });
}, [open, entry]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const clientEngagements = form.client_id
? engagements.filter(e => String(e.client_id) === String(form.client_id))
: [];
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
const body = { ...form, client_id: form.client_id || null, engagement_id: form.engagement_id || null };
if (entry) await api.put(`/time/${entry.id}`, body);
else await api.post('/time', body);
onSaved();
onClose();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title={entry ? 'Uren bewerken' : 'Uren toevoegen'}>
<form onSubmit={submit} className="space-y-4">
<ErrorBox error={error} />
<div className="grid grid-cols-2 gap-3">
<Field label="Cliënt">
<select className={inputCls} value={form.client_id} onChange={e => setForm(f => ({ ...f, client_id: e.target.value, engagement_id: '' }))}>
<option value=""> Geen </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<Field label="Engagement">
<select
className={`${inputCls} disabled:opacity-50`}
value={form.engagement_id}
onChange={e => set('engagement_id', e.target.value)}
disabled={!form.client_id}
>
<option value=""> Geen </option>
{clientEngagements.map(e => <option key={e.id} value={e.id}>{e.title} {e.client_name}</option>)}
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Datum *">
<input type="date" required className={inputCls} value={form.date} onChange={e => set('date', e.target.value)} />
</Field>
<Field label="Uren *">
<input type="number" required step="0.25" min="0" className={inputCls} value={form.hours} onChange={e => set('hours', e.target.value)} placeholder="bijv. 2.5" />
</Field>
</div>
<Field label="Omschrijving">
<textarea className={inputCls} rows={2} value={form.description} onChange={e => set('description', e.target.value)} placeholder="Wat heb je gedaan?" />
</Field>
<div className="grid grid-cols-2 gap-3 items-end">
<Field label="Uurtarief">
<input type="number" step="0.01" min="0" className={inputCls} value={form.hourly_rate} onChange={e => set('hourly_rate', e.target.value)} />
</Field>
<label className="flex items-center gap-2 pb-2 cursor-pointer">
<input type="checkbox" className="w-4 h-4 accent-accent" checked={form.billable} onChange={e => set('billable', e.target.checked)} />
<span className="text-[13px]">Facturabel</span>
</label>
</div>
<div className="flex justify-end gap-2 pt-1">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>{entry ? 'Opslaan' : 'Toevoegen'}</Btn>
</div>
</form>
</Modal>
);
}
function InvoiceModal({ open, onClose, clients }) {
const [form, setForm] = useState({ client_id: '', date_from: '', date_to: '' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [created, setCreated] = useState(null);
useEffect(() => {
if (!open) return;
setForm({ client_id: '', date_from: '', date_to: '' });
setError(null);
setCreated(null);
}, [open]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
const res = await api.post('/time/create-invoice', {
client_id: form.client_id,
date_from: form.date_from || undefined,
date_to: form.date_to || undefined
});
setCreated(res);
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Factuur maken uit uren">
{created ? (
<div className="space-y-4">
<div className="bg-green/10 border border-green/30 rounded-lg px-4 py-3 text-[13px] text-green">
Factuur <span className="font-semibold">{created.number}</span> is aangemaakt uit de facturabele uren.
</div>
<div className="flex justify-end gap-2">
<Btn variant="secondary" onClick={onClose}>Sluiten</Btn>
<Link to={`/invoices/${created.id}`}>
<Btn>Bekijk factuur</Btn>
</Link>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<ErrorBox error={error} />
<Field label="Cliënt *">
<select required className={inputCls} value={form.client_id} onChange={e => set('client_id', e.target.value)}>
<option value=""> Kies een cliënt </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Van datum">
<input type="date" className={inputCls} value={form.date_from} onChange={e => set('date_from', e.target.value)} />
</Field>
<Field label="Tot datum">
<input type="date" className={inputCls} value={form.date_to} onChange={e => set('date_to', e.target.value)} />
</Field>
</div>
<p className="text-[12px] text-muted">Alle facturabele uren van deze cliënt (binnen de periode) worden op één conceptfactuur gezet.</p>
<div className="flex justify-end gap-2 pt-1">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Factuur maken</Btn>
</div>
</form>
)}
</Modal>
);
}
export default function Time() {
const [data, setData] = useState(null);
const [clients, setClients] = useState([]);
const [engagements, setEngagements] = useState([]);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({ client_id: '', date_from: '', date_to: '' });
const [entryModal, setEntryModal] = useState({ open: false, entry: null });
const [invoiceOpen, setInvoiceOpen] = useState(false);
useEffect(() => {
api.get('/clients').then(setClients).catch(() => {});
api.get('/engagements').then(setEngagements).catch(() => {});
}, []);
const load = () => {
setError(null);
api.get('/time' + qs(filters)).then(setData).catch(setError);
};
useEffect(load, [filters]);
const estimatedValue = useMemo(() => {
if (!data) return 0;
return data.entries
.filter(e => e.billable)
.reduce((s, e) => s + e.hours * (e.hourly_rate || 0), 0);
}, [data]);
const remove = async (entry) => {
if (!window.confirm(`${fmt.hours(entry.hours)} op ${fmt.date(entry.date)} verwijderen?`)) return;
try {
await api.del(`/time/${entry.id}`);
load();
} catch (err) {
alert(err.message);
}
};
const setFilter = (k, v) => setFilters(f => ({ ...f, [k]: v }));
return (
<div className="space-y-5 fade-in">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-xl font-bold tracking-tight">Urenregistratie</h1>
<p className="text-[13px] text-muted mt-0.5">Registreer en factureer je gewerkte uren</p>
</div>
<div className="flex items-center gap-2">
<Btn variant="secondary" onClick={() => setInvoiceOpen(true)}>
<FileText className="w-3.5 h-3.5" /> Factuur maken uit uren
</Btn>
<Btn onClick={() => setEntryModal({ open: true, entry: null })}>
<Plus className="w-3.5 h-3.5" /> Uren toevoegen
</Btn>
</div>
</div>
{error ? (
<ErrorBox error={error} onRetry={load} />
) : !data ? (
<Spinner />
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<KpiCard icon={Clock} label="Totaal uren" value={fmt.hours(data.totalHours)} color="blue" />
<KpiCard icon={Clock} label="Facturabel" value={fmt.hours(data.billableHours)} color="green" />
<KpiCard icon={Clock} label="Niet-facturabel" value={fmt.hours(data.totalHours - data.billableHours)} color="yellow" />
<KpiCard icon={Euro} label="Geschatte waarde" value={fmt.euro(estimatedValue)} sub="facturabele uren × tarief" color="accent" />
</div>
<Card>
<div className="flex items-center gap-2 flex-wrap mb-4">
<select className={`${inputCls} !w-auto`} value={filters.client_id} onChange={e => setFilter('client_id', e.target.value)}>
<option value="">Alle cliënten</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<input type="date" className={`${inputCls} !w-auto`} value={filters.date_from} onChange={e => setFilter('date_from', e.target.value)} title="Van datum" />
<span className="text-muted text-[12px]">t/m</span>
<input type="date" className={`${inputCls} !w-auto`} value={filters.date_to} onChange={e => setFilter('date_to', e.target.value)} title="Tot datum" />
{(filters.client_id || filters.date_from || filters.date_to) && (
<Btn variant="ghost" size="sm" onClick={() => setFilters({ client_id: '', date_from: '', date_to: '' })}>Filters wissen</Btn>
)}
</div>
<Table
columns={[
{ label: 'Datum', render: r => <span className="text-muted">{fmt.date(r.date)}</span> },
{ label: 'Cliënt', render: r => r.client_name || '-' },
{ label: 'Engagement', render: r => r.engagement_title || '-' },
{ label: 'Omschrijving', render: r => <span className="text-muted">{r.description || '-'}</span> },
{ label: 'Uren', render: r => fmt.hours(r.hours) },
{ label: 'Tarief', render: r => fmt.euro(r.hourly_rate) },
{ label: 'Facturabel', render: r => r.billable ? <Badge color="green">Ja</Badge> : <Badge color="muted">Nee</Badge> },
{ label: 'Gebruiker', render: r => <span className="text-muted">{r.username || '-'}</span> },
{
label: 'Acties', render: r => (
<div className="flex items-center gap-1">
<button onClick={() => setEntryModal({ open: true, entry: r })} className="p-1.5 rounded-md text-muted hover:text-text hover:bg-bg-soft" title="Bewerken">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => remove(r)} className="p-1.5 rounded-md text-muted hover:text-red hover:bg-red/10" title="Verwijderen">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)
}
]}
rows={data.entries}
keyFn={r => r.id}
empty={<EmptyState icon={Clock} title="Geen uren gevonden" hint="Voeg je eerste tijdregistratie toe of pas de filters aan." />}
/>
</Card>
</>
)}
<EntryModal
open={entryModal.open}
entry={entryModal.entry}
clients={clients}
engagements={engagements}
onClose={() => setEntryModal({ open: false, entry: null })}
onSaved={load}
/>
<InvoiceModal open={invoiceOpen} onClose={() => { setInvoiceOpen(false); load(); }} clients={clients} />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
base: '/app/',
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://192.168.1.247:3000',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
sourcemap: false,
chunkSizeWarningLimit: 900
}
})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mek-Tech — Consultancy Platform</title>
<script type="module" crossorigin src="/app/assets/index-CQEBt6sl.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-A3FsIOhP.css">
</head>
<body>
<div id="root"></div>
</body>
</html>