audit-log
FC-0058audit-logblockv1.0.0Logboek van gebeurtenissen met een lijn door de iconen, labels voor soort en status, filters op wie/soort/status plus zoekveld, en per regel een rechtsklik- of ⋯-menu met acties van het project. Componeert search-field.
- Site gepubliceerd v2.42 min geleden
Release naar productie gezet.
Sannedeploygelukt - Teamlid uitgenodigd1 uur geleden
tom@voorbeeld.nl toegevoegd aan het project.
Priyateamwacht - API-sleutel vernieuwd3 uur geleden
De oude sleutel is direct ingetrokken.
Systeembeveiliginggelukt - Back-up misluktgisteren
Opslag niet bereikbaar; opnieuw gepland.
Systeemonderhoudmislukt
Rechtsklik op een regel of gebruik ⋯. Laatst: —
blocks/audit-log/audit-log.tsx
/**
* AuditLog — een logboek van gebeurtenissen (wie deed wat, wanneer) met een
* lijn door de iconen, labels voor soort en status, filters op wie/soort/
* status en een zoekveld, en per regel een rechtsklik- of ⋯-menu met acties
* van het project.
*
* De bron leunde op radix-context-menu, een 400-regel Filters-component en
* lucide. Hier: native selects voor de drie filters, search-field (FC-0048)
* voor de tekst, en een klein eigen contextmenu op de plek van de muis (ook
* via de ⋯-knop voor toetsenbord en touch). Items, iconen en acties komen
* van het project; het tijdstip is een string die het project al opmaakte.
*/
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { usePress } from "../../primitives/ui/press";
import { SearchField } from "../../primitives/ui/search-field";
export interface AuditLogItem {
id: string;
title: string;
description?: string;
/** Al opgemaakt door het project ("2 min geleden", "gisteren"). */
timestamp: string;
actor?: string;
type?: string;
status?: string;
icon?: React.ReactNode;
}
export interface AuditLogAction {
id: string;
label: string;
icon?: React.ReactNode;
onSelect: (item: AuditLogItem) => void;
}
export interface AuditLogProps extends React.HTMLAttributes<HTMLDivElement> {
items: readonly AuditLogItem[];
actions?: readonly AuditLogAction[];
filters?: boolean;
labels?: { actor?: string; type?: string; status?: string; all?: string; search?: string; empty?: string; menu?: string };
}
const uniek = (items: readonly AuditLogItem[], k: "actor" | "type" | "status") => Array.from(new Set(items.map((i) => i[k]).filter(Boolean) as string[])).sort();
function MenuItem({ a, item, onDone }: { a: AuditLogAction; item: AuditLogItem; onDone: () => void }) {
const { pressProps } = usePress({ onPress: () => { a.onSelect(item); onDone(); } });
return (
<li {...pressProps} role="menuitem" className="fp-audit__menu-item">
{a.icon && <span className="fp-audit__menu-icoon">{a.icon}</span>}
{a.label}
</li>
);
}
function Select({ label, value, onChange, opties, all }: { label: string; value: string; onChange: (v: string) => void; opties: string[]; all: string }) {
return (
<label className="fp-audit__filter">
<span className="fp-audit__filter-label">{label}</span>
<select className="fp-audit__select" value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{all}</option>
{opties.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
);
}
function Meer({ label, onPress }: { label: string; onPress: () => void }) {
const { pressed, pressProps } = usePress({ onPress });
return (
<button {...pressProps} type="button" className="fp-audit__meer" aria-label={label} aria-haspopup="menu" data-pressed={pressed ? "" : undefined}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle cx="19" cy="12" r="2" /></svg>
</button>
);
}
export function AuditLog({ items, actions = [], filters = true, labels, className, ...rest }: AuditLogProps) {
const t = { actor: "Wie", type: "Soort", status: "Status", all: "Alles", search: "Zoek in het logboek", empty: "Geen gebeurtenissen voor deze filters.", menu: "Acties", ...labels };
const id = useId().replace(/:/g, "");
const pop = useSpringTransition("pop", { mass: "content" });
const [actor, setActor] = useState("");
const [type, setType] = useState("");
const [status, setStatus] = useState("");
const [zoek, setZoek] = useState("");
const [menu, setMenu] = useState<{ item: AuditLogItem; x: number; y: number } | null>(null);
const wortel = useRef<HTMLDivElement>(null);
const zichtbaar = useMemo(() => {
const q = zoek.trim().toLowerCase();
return items.filter((i) =>
(!actor || i.actor === actor) && (!type || i.type === type) && (!status || i.status === status) &&
(!q || [i.title, i.description, i.actor, i.type, i.status].filter(Boolean).join(" ").toLowerCase().includes(q)),
);
}, [items, actor, type, status, zoek]);
useEffect(() => {
if (!menu) return;
const sluit = (e: Event) => { if (!(e.target instanceof Node) || !wortel.current?.querySelector(".fp-audit__menu")?.contains(e.target)) setMenu(null); };
const esc = (e: KeyboardEvent) => { if (e.key === "Escape") setMenu(null); };
document.addEventListener("pointerdown", sluit);
document.addEventListener("keydown", esc);
return () => { document.removeEventListener("pointerdown", sluit); document.removeEventListener("keydown", esc); };
}, [menu]);
const openMenu = (item: AuditLogItem, e: { clientX: number; clientY: number } | null, rij: HTMLElement) => {
if (actions.length === 0) return;
const r = wortel.current!.getBoundingClientRect();
const rr = rij.getBoundingClientRect();
setMenu({ item, x: e ? e.clientX - r.left : rr.right - r.left - 176, y: e ? e.clientY - r.top : rr.top - r.top + 36 });
};
return (
<div {...rest} ref={wortel} className={["fp-audit", className].filter(Boolean).join(" ")}>
{filters && (
<div className="fp-audit__filters">
<Select label={t.actor} all={t.all} value={actor} onChange={setActor} opties={uniek(items, "actor")} />
<Select label={t.type} all={t.all} value={type} onChange={setType} opties={uniek(items, "type")} />
<Select label={t.status} all={t.all} value={status} onChange={setStatus} opties={uniek(items, "status")} />
<SearchField className="fp-audit__zoek" placeholder={t.search} shortcut="" value={zoek} onChange={(e) => setZoek(e.target.value)} />
</div>
)}
<ol className="fp-audit__lijst">
{zichtbaar.length === 0 && <li className="fp-audit__leeg">{t.empty}</li>}
{zichtbaar.map((item, i) => (
<li
key={item.id}
className="fp-audit__rij"
onContextMenu={(e) => { if (actions.length) { e.preventDefault(); openMenu(item, e, e.currentTarget); } }}
>
{i < zichtbaar.length - 1 && <span aria-hidden="true" className="fp-audit__lijn" />}
<span className="fp-audit__icoon">{item.icon}</span>
<div className="fp-audit__inhoud">
<div className="fp-audit__kop">
<span className="fp-audit__titel">{item.title}</span>
<span className="fp-audit__tijd">{item.timestamp}</span>
</div>
{item.description && <p className="fp-audit__tekst">{item.description}</p>}
<div className="fp-audit__labels">
{item.actor && <span className="fp-audit__wie">{item.actor}</span>}
{item.type && <span className="fp-audit__label">{item.type}</span>}
{item.status && <span className="fp-audit__label" data-status={item.status}>{item.status}</span>}
</div>
</div>
{actions.length > 0 && <Meer label={t.menu} onPress={() => openMenu(item, null, wortel.current!.querySelectorAll<HTMLElement>(".fp-audit__rij")[i]!)} />}
</li>
))}
</ol>
<AnimatePresence>
{menu && (
/* De plek is statisch (gewone div); alleen het menu zelf beweegt. */
<div className="fp-audit__menu-plek" style={{ left: menu.x, top: menu.y }}>
<motion.ul
id={`${id}-menu`}
role="menu"
className="fp-audit__menu"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={pop}
>
{actions.map((a) => <MenuItem key={a.id} a={a} item={menu.item} onDone={() => setMenu(null)} />)}
</motion.ul>
</div>
)}
</AnimatePresence>
</div>
);
}
blocks/audit-log/audit-log.css
/** AuditLog — importeren in de app-root (registry-site: app/globals.css). */
.fp-audit { position: relative; display: grid; gap: 0.75rem; width: 100%; color: var(--foreground); }
.fp-audit__filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
.fp-audit__filter { display: flex; flex-direction: column; gap: 0.25rem; }
.fp-audit__filter-label { font-size: 0.6875rem; font-weight: 500; color: var(--muted-foreground); }
.fp-audit__select {
appearance: none; -webkit-appearance: none;
height: 2rem; padding: 0 1.75rem 0 0.625rem;
font: inherit; font-size: 0.8125rem; color: var(--foreground);
background: var(--muted); border: 1px solid var(--border); border-radius: calc(var(--radius) / 1.5); cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 0.9rem) 55%, calc(100% - 0.65rem) 55%; background-size: 0.25rem 0.25rem; background-repeat: no-repeat;
}
.fp-audit__select:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 1px; }
.fp-audit__zoek { flex: 1; min-width: 12rem; }
.fp-audit__zoek .fp-input { height: 2rem; padding-top: 0; padding-bottom: 0; }
.fp-audit__lijst { list-style: none; margin: 0; padding: 0; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); }
.fp-audit__rij { position: relative; display: flex; gap: 0.75rem; padding: 0.75rem 1rem; }
.fp-audit__lijn { position: absolute; top: 2.25rem; bottom: 0; left: 1.55rem; width: 1px; background: var(--border); }
.fp-audit__icoon { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--border); background: var(--background); color: var(--muted-foreground); font-size: 0.6875rem; }
.fp-audit__inhoud { min-width: 0; flex: 1; }
.fp-audit__kop { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem; }
.fp-audit__titel { font-size: 0.875rem; font-weight: 500; }
.fp-audit__tijd { font-size: 0.75rem; color: var(--muted-foreground); }
.fp-audit__tekst { margin: 0.25rem 0 0; font-size: 0.75rem; line-height: 1.6; color: var(--muted-foreground); }
.fp-audit__labels { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 0.5rem; font-size: 0.75rem; }
.fp-audit__wie { font-weight: 500; }
.fp-audit__label { padding: 0.125rem 0.5rem; color: var(--muted-foreground); border: 1px solid var(--border); border-radius: 999px; }
.fp-audit__meer {
display: flex; align-self: flex-start; padding: 0.25rem;
appearance: none; border: 0; background: transparent; cursor: pointer; color: var(--muted-foreground); border-radius: calc(var(--radius) / 2);
transition: background-color var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-audit__meer:hover { background: var(--muted); color: var(--foreground); }
.fp-audit__meer[data-pressed] { transform: scale(0.9); }
.fp-audit__meer:focus-visible { outline: 2px solid var(--ring, var(--info)); }
.fp-audit__leeg { padding: 2rem 1rem; text-align: center; font-size: 0.875rem; color: var(--muted-foreground); }
.fp-audit__menu-plek { position: absolute; z-index: 30; }
.fp-audit__menu {
list-style: none; margin: 0; padding: 0.25rem; min-width: 11rem;
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-overlay);
transform-origin: top left;
}
.fp-audit__menu-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem; font-size: 0.875rem; cursor: pointer; border-radius: calc(var(--radius) / 2); transition: background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-audit__menu-item:hover { background: var(--muted); }
.fp-audit__menu-icoon { display: flex; width: 1rem; height: 1rem; color: var(--muted-foreground); }