2025-11-24 08:59:14 +01:00

383 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// components/ui/Feed.tsx
'use client';
import * as React from 'react';
import clsx from 'clsx';
import PersonAvatar from '@/components/ui/UserAvatar';
/* ───────── Types ───────── */
export type FeedPerson = {
/**
* Fallback-Anzeigename z.B. E-Mail, wenn sonst nichts da ist.
* In der Anzeige wird aber bevorzugt:
* arbeitsname > "Vorname Nachname" > nwkennung > email > name
*/
name: string;
href?: string;
imageUrl?: string; // optional: Avatar-Bild
// Zusätzliche Infos, damit wir einen schönen Display-Namen bauen können
arbeitsname?: string | null;
firstName?: string | null;
lastName?: string | null;
nwkennung?: string | null;
email?: string | null;
};
export type FeedTag = {
name: string;
href?: string;
/** z.B. 'fill-red-500' */
color?: string;
};
export type FeedChange = {
field: string;
/** Anzeigename, z.B. "Standort" statt "location" */
label?: string;
from: string | null;
to: string | null;
};
export type FeedItem =
| {
id: string | number;
type: 'comment';
person: FeedPerson;
imageUrl?: string;
comment: string;
/** Art des Kommentars steuert nur noch Text, nicht mehr das Icon */
commentKind?: 'created' | 'deleted' | 'generic' | 'loaned' | 'returned';
}
| {
id: string | number;
type: 'assignment';
person: FeedPerson;
assigned: FeedPerson;
date: string;
}
| {
id: string | number;
type: 'tags';
person: FeedPerson;
tags: FeedTag[];
date: string;
}
| {
id: string | number;
type: 'change';
person: FeedPerson;
changes: FeedChange[];
date: string;
};
export interface FeedProps {
items: FeedItem[];
className?: string;
}
/* ───────── Helper ───────── */
// DE Datum+Uhrzeit für Änderungen
const dtfDateTime = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
});
/**
* Erzeugt den eigentlichen Anzeigenamen für eine Person:
* arbeitsname > "Vorname Nachname" > nwkennung > email > name
*/
function getDisplayName(person: FeedPerson): string {
return (
person.arbeitsname ??
(person.firstName && person.lastName
? `${person.firstName} ${person.lastName}`
: person.nwkennung ?? person.email ?? person.name)
);
}
/** Werte für "change"-Einträge formatiert darstellen */
function formatChangeValue(field: string, value: string | null): string {
if (!value) return '—';
// Für Verleih-Daten: schön als Datum + Uhrzeit anzeigen
if (field === 'loanedFrom' || field === 'loanedUntil') {
const d = new Date(value);
if (!isNaN(d.getTime())) {
return dtfDateTime.format(d);
}
}
// alles andere unverändert
return value;
}
// sprechende Zusammenfassung für "change"
function getChangeSummary(
item: Extract<FeedItem, { type: 'change' }>,
): React.ReactNode {
const { changes } = item;
if (!changes.length) return 'hat Änderungen vorgenommen';
// Sonderfall: nur Tags
if (changes.length === 1 && changes[0].field === 'tags') {
const c = changes[0];
const beforeList = (c.from ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const afterList = (c.to ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const beforeLower = beforeList.map((x) => x.toLowerCase());
const afterLower = afterList.map((x) => x.toLowerCase());
const added = afterList.filter(
(t) => !beforeLower.includes(t.toLowerCase()),
);
const removed = beforeList.filter(
(t) => !afterLower.includes(t.toLowerCase()),
);
const parts: string[] = [];
if (added.length) parts.push(`hinzugefügt: ${added.join(', ')}`);
if (removed.length) parts.push(`entfernt: ${removed.join(', ')}`);
if (parts.length > 0) {
return (
<>
hat Tags <em>{parts.join(' · ')}</em>
</>
);
}
return 'hat Tags angepasst';
}
if (changes.length === 1) {
const c = changes[0];
const label = c.label ?? c.field;
return (
<>
hat <em>{label}</em> geändert
</>
);
}
const labels = changes.map((c) => c.label ?? c.field);
const uniqueLabels = Array.from(new Set(labels));
const maxShow = 3;
if (uniqueLabels.length <= maxShow) {
return (
<>
hat{' '}
{uniqueLabels.map((label, index) => (
<React.Fragment key={label}>
<em>{label}</em>
{index < uniqueLabels.length - 1 ? ', ' : ' '}
</React.Fragment>
))}
geändert
</>
);
}
const first = uniqueLabels.slice(0, maxShow);
return (
<>
hat{' '}
{first.map((label, index) => (
<React.Fragment key={label}>
<em>{label}</em>
{index < first.length - 1 ? ', ' : ' '}
</React.Fragment>
))}
und weitere geändert
</>
);
}
function formatRelativeDate(raw: string): string {
const d = new Date(raw);
if (isNaN(d.getTime())) {
// Falls kein gültiges Datum (z.B. schon formatiert), einfach roh anzeigen
return raw;
}
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 5) return 'gerade eben';
if (diffSec < 60) return `vor ${diffSec} Sekunden`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) {
return `vor ${diffMin} Minute${diffMin === 1 ? '' : 'n'}`;
}
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) {
return `vor ${diffH} Stunde${diffH === 1 ? '' : 'n'}`;
}
const diffD = Math.floor(diffH / 24);
if (diffD < 30) {
return `vor ${diffD} Tag${diffD === 1 ? '' : 'en'}`;
}
// Fallback: für ältere Einträge wieder absolutes Datum
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(d);
}
/* ───────── Component ───────── */
export default function Feed({ items, className }: FeedProps) {
if (!items.length) {
return (
<p
className={clsx(
'text-sm text-gray-500 dark:text-gray-400',
className,
)}
>
Keine Aktivitäten vorhanden.
</p>
);
}
return (
<ul role="list" className={clsx('pb-4', className)}>
{items.map((item, idx) => {
const actor = item.person;
const actorDisplayName = getDisplayName(actor);
// Textinhalt (content)
let content: React.ReactNode = null;
if (item.type === 'comment') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{actorDisplayName}
</span>{' '}
<span className="text-gray-300 dark:text-gray-200">
{item.comment}
</span>
</p>
);
} else if (item.type === 'assignment') {
const assignedDisplayName = getDisplayName(item.assigned);
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{actorDisplayName}
</span>{' '}
hat{' '}
<span className="font-medium text-gray-900 dark:text-white">
{assignedDisplayName}
</span>{' '}
zugewiesen.
</p>
);
} else if (item.type === 'tags') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{actorDisplayName}
</span>{' '}
hat Tags hinzugefügt:{' '}
<span className="font-medium text-gray-900 dark:text-gray-100">
{item.tags.map((t) => t.name).join(', ')}
</span>
</p>
);
} else if (item.type === 'change') {
const summary = getChangeSummary(item);
content = (
<div className="space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{actorDisplayName}
</span>{' '}
{summary}
</p>
{item.changes.length > 0 && (
<p className="text-[11px] text-gray-400 dark:text-gray-500">
{item.changes.slice(0, 2).map((c, i) => (
<span
key={`${c.field}-${i}`}
className="flex flex-wrap items-baseline gap-x-1"
>
<span className="line-through text-red-500/80 dark:text-red-400/90">
{formatChangeValue(c.field, c.from)}
</span>
<span className="text-gray-400"></span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">
{formatChangeValue(c.field, c.to)}
</span>
{i < Math.min(2, item.changes.length) - 1 && (
<span className="mx-1 text-gray-500 dark:text-gray-600">
·
</span>
)}
</span>
))}
{item.changes.length > 2 && (
<span className="ml-1 text-gray-500 dark:text-gray-600">
·
</span>
)}
</p>
)}
</div>
);
}
return (
<li key={item.id}>
<div className="relative pb-6">
{idx !== items.length - 1 ? (
<span
aria-hidden="true"
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
/>
) : null}
<div className="relative flex space-x-3">
{/* Avatar statt Icon-Badge */}
<div className="mt-0.5">
<PersonAvatar
name={actorDisplayName}
avatarUrl={actor.imageUrl}
size="md"
/>
</div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>{content}</div>
<div
className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400"
title={item.date}
>
{formatRelativeDate(item.date)}
</div>
</div>
</div>
</div>
</li>
);
})}
</ul>
);
}