2026-07-21 21:42:25 +02:00
|
|
|
import { useEffect } from 'react';
|
2026-07-21 22:38:50 +02:00
|
|
|
import { useNavigate } from 'react-router-dom';
|
2026-07-21 21:42:25 +02:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 22:00:39 +02:00
|
|
|
export function KpiCard({ icon: Icon, label, value, sub, color = 'accent', to, iconColor: iconCol }) {
|
2026-07-21 22:38:50 +02:00
|
|
|
const navigate = useNavigate();
|
2026-07-21 21:42:25 +02:00
|
|
|
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'
|
|
|
|
|
};
|
2026-07-21 22:38:50 +02:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
onClick={to ? () => navigate(to) : undefined}
|
|
|
|
|
className={`bg-card border border-border-soft rounded-xl p-4 card-hover h-full ${to ? 'cursor-pointer' : ''}`}
|
|
|
|
|
>
|
2026-07-21 21:42:25 +02:00
|
|
|
<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}`}>
|
2026-07-21 22:00:39 +02:00
|
|
|
<Icon className="w-[18px] h-[18px]" style={{ color: iconCol || 'currentColor' }} />
|
2026-07-21 21:42:25 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|