← alle entries

audit-log

FC-0058audit-logblockv1.0.0

Logboek 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.

zelfde component — andere tokens
  1. Site gepubliceerd v2.42 min geleden

    Release naar productie gezet.

    Sannedeploygelukt
  2. Teamlid uitgenodigd1 uur geleden

    tom@voorbeeld.nl toegevoegd aan het project.

    Priyateamwacht
  3. API-sleutel vernieuwd3 uur geleden

    De oude sleutel is direct ingetrokken.

    Systeembeveiliginggelukt
  4. 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); }