feat: React 19 SPA frontend (v3.0) — volledige moderne UI op /app
- 24 pagina's: dashboard, clients, engagements, tasks, calendar, email, projects, time, invoices, finance, racks (visuele rack-view), networking, diagrams, market intel, AI studio (providers+chat), notifications, search, settings (algemeen/users/backup/audit) - Vite + React 19 + Tailwind v4 + recharts + lucide, dark neon thema, NL - Build gedeployed naar public/spa, geserveerd door Express op /app
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { useEffect } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
|
||||
// ---- Kleurmapping voor statussen ----
|
||||
export const STATUS_COLORS = {
|
||||
// clients
|
||||
lead: 'yellow', active: 'green', paused: 'muted', archived: 'muted',
|
||||
// engagements
|
||||
planned: 'blue', in_progress: 'accent', completed: 'green', on_hold: 'yellow',
|
||||
// tasks
|
||||
todo: 'blue', done: 'green', cancelled: 'muted',
|
||||
// facturen
|
||||
draft: 'muted', sent: 'blue', paid: 'green', overdue: 'red',
|
||||
// overig
|
||||
critical: 'red', high: 'yellow', medium: 'blue', low: 'muted',
|
||||
planning: 'blue', inactive: 'muted', standby: 'yellow', decommissioned: 'muted',
|
||||
new: 'yellow', contacted: 'blue', qualified: 'accent', converted: 'green', lost: 'muted'
|
||||
};
|
||||
|
||||
const BADGE_STYLES = {
|
||||
accent: 'bg-accent-soft text-accent',
|
||||
green: 'bg-green/15 text-green',
|
||||
yellow: 'bg-yellow/15 text-yellow',
|
||||
red: 'bg-red/15 text-red',
|
||||
blue: 'bg-blue/15 text-blue',
|
||||
purple: 'bg-purple/15 text-purple',
|
||||
pink: 'bg-pink/15 text-pink',
|
||||
muted: 'bg-muted/15 text-muted'
|
||||
};
|
||||
|
||||
export function Badge({ color = 'muted', children, className = '' }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${BADGE_STYLES[color] || BADGE_STYLES.muted} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, labels = {} }) {
|
||||
return <Badge color={STATUS_COLORS[status] || 'muted'}>{labels[status] || status}</Badge>;
|
||||
}
|
||||
|
||||
export function Card({ title, subtitle, actions, children, className = '', padding = true }) {
|
||||
return (
|
||||
<div className={`bg-card border border-border-soft rounded-xl ${className}`}>
|
||||
{(title || actions) && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-soft">
|
||||
<div>
|
||||
<h3 className="text-[14px] font-semibold">{title}</h3>
|
||||
{subtitle && <div className="text-[12px] text-muted mt-0.5">{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className={padding ? 'p-4' : ''}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCard({ icon: Icon, label, value, sub, color = 'accent', to }) {
|
||||
const colors = {
|
||||
accent: 'text-accent bg-accent-soft',
|
||||
green: 'text-green bg-green/15',
|
||||
yellow: 'text-yellow bg-yellow/15',
|
||||
red: 'text-red bg-red/15',
|
||||
blue: 'text-blue bg-blue/15',
|
||||
purple: 'text-purple bg-purple/15'
|
||||
};
|
||||
const inner = (
|
||||
<div className="bg-card border border-border-soft rounded-xl p-4 card-hover h-full">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-[12px] text-muted mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold tracking-tight">{value}</div>
|
||||
{sub && <div className="text-[12px] text-muted mt-1">{sub}</div>}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${colors[color] || colors.accent}`}>
|
||||
<Icon className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return to ? <a href={to} className="block">{inner}</a> : inner;
|
||||
}
|
||||
|
||||
const BTN_VARIANTS = {
|
||||
primary: 'bg-accent text-bg hover:bg-accent/90 font-semibold',
|
||||
secondary: 'bg-card border border-border text-text hover:bg-card-hover',
|
||||
danger: 'bg-red/15 text-red hover:bg-red/25',
|
||||
ghost: 'text-muted hover:text-text hover:bg-card'
|
||||
};
|
||||
|
||||
export function Btn({ variant = 'primary', size = 'md', loading, disabled, children, className = '', ...props }) {
|
||||
const sizes = { sm: 'px-2.5 py-1 text-[12px]', md: 'px-3.5 py-2 text-[13px]', lg: 'px-5 py-2.5 text-[14px]' };
|
||||
return (
|
||||
<button
|
||||
disabled={disabled || loading}
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${BTN_VARIANTS[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, error, children, className = '' }) {
|
||||
return (
|
||||
<label className={`block ${className}`}>
|
||||
{label && <span className="block text-[12px] text-muted mb-1">{label}</span>}
|
||||
{children}
|
||||
{error && <span className="block text-[12px] text-red mt-1">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const inputCls =
|
||||
'w-full bg-bg-soft border border-border rounded-lg px-3 py-2 text-[13px] placeholder:text-muted focus:outline-none focus:border-accent/60 transition-colors';
|
||||
|
||||
export function Modal({ open, onClose, title, children, wide }) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => e.key === 'Escape' && onClose?.();
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
|
||||
<div className={`relative bg-card border border-border rounded-xl w-full ${wide ? 'max-w-3xl' : 'max-w-lg'} max-h-[90vh] overflow-y-auto fade-in`}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-soft sticky top-0 bg-card z-10">
|
||||
<h2 className="text-[15px] font-semibold">{title}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md text-muted hover:text-text hover:bg-bg-soft">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ text = 'Laden...' }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-muted">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-[13px]">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, hint, action }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
{Icon && (
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-soft flex items-center justify-center mb-3">
|
||||
<Icon className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[14px] font-medium">{title}</div>
|
||||
{hint && <div className="text-[12px] text-muted mt-1 max-w-sm">{hint}</div>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBox({ error, onRetry }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="bg-red/10 border border-red/30 rounded-lg px-4 py-3 text-[13px] text-red flex items-center justify-between">
|
||||
<span>{String(error.message || error)}</span>
|
||||
{onRetry && (
|
||||
<button onClick={onRetry} className="underline hover:no-underline ml-3 shrink-0">
|
||||
Opnieuw
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data-tabel met uniforme styling
|
||||
export function Table({ columns, rows, keyFn, onRowClick, empty }) {
|
||||
if (!rows || rows.length === 0) {
|
||||
return empty || <div className="text-center text-muted text-[13px] py-8">Geen gegevens</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-4 px-4">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-border-soft">
|
||||
{columns.map((c, i) => (
|
||||
<th key={i} className={`pb-2 pr-4 font-medium text-[12px] ${c.className || ''}`}>{c.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={keyFn ? keyFn(row) : i}
|
||||
className={`border-b border-border-soft/50 table-row-hover ${onRowClick ? 'cursor-pointer' : ''}`}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((c, j) => (
|
||||
<td key={j} className={`py-2.5 pr-4 ${c.tdClassName || ''}`}>
|
||||
{c.render ? c.render(row) : row[c.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user