Files
mek-tech-consulting/frontend/src/components/ui.jsx
T

219 lines
8.0 KiB
React
Raw Normal View History

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>
);
}