383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
// 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>
|
||
);
|
||
}
|