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