teg/frontend/src/components/Journal.tsx
2026-07-03 16:56:20 +02:00

573 lines
21 KiB
TypeScript

// frontend\src\components\Journal.tsx
import { type ComponentType, type SVGProps, useMemo, useState } from 'react'
import {
ArrowRightIcon,
ChatBubbleLeftEllipsisIcon,
ClockIcon,
MagnifyingGlassIcon,
PaperAirplaneIcon,
PencilSquareIcon,
PlusCircleIcon,
} from '@heroicons/react/20/solid'
import Avatar from './Avatar'
import { Badge, type BadgeTone } from './Badge'
import Button from './Button'
import NotificationDot from './NotificationDot'
import Textarea from './Textarea'
type JournalIcon = ComponentType<SVGProps<SVGSVGElement>>
export type JournalChange = {
label: string
oldValue?: string
newValue?: string
}
export type JournalItem = {
id: string | number
type?: 'created' | 'edited' | 'journal' | 'comment'
content: string
description?: string
changes?: JournalChange[]
date: string
datetime?: string
dateTime?: string
editedDate?: string
editedDatetime?: string
editedDateTime?: string
canEdit?: boolean
hasNotification?: boolean
icon?: JournalIcon
iconBackground?: string
person?: {
name: string
href?: string
imageUrl?: string
}
imageUrl?: string
}
type JournalProps = {
items?: JournalItem[]
currentUserAvatar?: string
currentUserName?: string
showEntryForm?: boolean
showSearch?: boolean
searchQuery?: string
onSearchQueryChange?: (value: string) => void
isSubmitting?: boolean
emptyText?: string
placeholder?: string
submitLabel?: string
onEntrySubmit?: (content: string) => void | Promise<void>
onEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
className?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getItemPersonName(item: JournalItem) {
return item.person?.name ?? 'Unbekannt'
}
function getItemDateTime(item: JournalItem) {
return item.dateTime ?? item.datetime ?? ''
}
function getItemEditedDateTime(item: JournalItem) {
return item.editedDateTime ?? item.editedDatetime ?? ''
}
function getItemImageUrl(item: JournalItem) {
return item.person?.imageUrl ?? item.imageUrl
}
function getItemDescription(item: JournalItem) {
return item.description ?? item.content
}
function getChangeValue(value?: string) {
return value?.trim() || '—'
}
function getChangeKey(change: JournalChange, index: number) {
return `${change.label}-${index}`
}
function getItemSearchText(item: JournalItem) {
const changeParts = (item.changes ?? []).flatMap((change) => [
change.label,
change.oldValue,
change.newValue,
])
return [
getItemPersonName(item),
getItemDescription(item),
...changeParts,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
function getJournalIcon(item: JournalItem) {
if (item.icon) {
return item.icon
}
if (item.type === 'created') {
return PlusCircleIcon
}
if (item.type === 'edited') {
return PencilSquareIcon
}
return ChatBubbleLeftEllipsisIcon
}
function getJournalIconBackground(item: JournalItem) {
if (item.iconBackground) {
return item.iconBackground
}
if (item.type === 'created') {
return 'bg-green-500'
}
if (item.type === 'edited') {
return 'bg-blue-500'
}
return 'bg-indigo-500'
}
function getJournalType(item: JournalItem): {
label: string
tone: BadgeTone
} {
if (item.type === 'created') {
return { label: 'Erstellt', tone: 'green' }
}
if (item.type === 'edited') {
return { label: 'Änderung', tone: 'blue' }
}
if (item.type === 'comment') {
return { label: 'Kommentar', tone: 'gray' }
}
return { label: 'Journal', tone: 'indigo' }
}
export default function Journal({
items = [],
currentUserAvatar,
currentUserName = 'Du',
showEntryForm = true,
showSearch = true,
searchQuery,
onSearchQueryChange,
isSubmitting = false,
emptyText = 'Noch keine Journal-Einträge vorhanden.',
placeholder = 'Journal-Eintrag hinzufügen...',
submitLabel = 'Eintrag hinzufügen',
onEntrySubmit,
onEntryUpdate,
className,
}: JournalProps) {
const [entry, setEntry] = useState('')
const [editingItemId, setEditingItemId] = useState<string | number | null>(null)
const [editEntry, setEditEntry] = useState('')
const [internalSearchQuery, setInternalSearchQuery] = useState('')
const activeSearchQuery = searchQuery ?? internalSearchQuery
function updateSearchQuery(value: string) {
if (onSearchQueryChange) {
onSearchQueryChange(value)
return
}
setInternalSearchQuery(value)
}
const filteredItems = useMemo(() => {
const normalizedQuery = activeSearchQuery.trim().toLowerCase()
if (!normalizedQuery) {
return items
}
return items.filter((item) => getItemSearchText(item).includes(normalizedQuery))
}, [items, activeSearchQuery])
async function handleSubmit() {
const content = entry.trim()
if (!content || isSubmitting) {
return
}
await onEntrySubmit?.(content)
setEntry('')
}
function startEditing(item: JournalItem) {
setEditingItemId(item.id)
setEditEntry(getItemDescription(item))
}
function cancelEditing() {
setEditingItemId(null)
setEditEntry('')
}
async function handleUpdate(itemId: string | number) {
const content = editEntry.trim()
if (!content || isSubmitting) {
return
}
await onEntryUpdate?.(itemId, content)
cancelEditing()
}
return (
<div className={className}>
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-950 dark:text-white">
Journal
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verlauf, Änderungen und manuelle Einträge.
</p>
</div>
</div>
{showEntryForm && (
<div className="mb-8">
<Textarea
variant="avatar-actions"
id="journalEntry"
name="journalEntry"
label="Journal-Eintrag hinzufügen"
srOnlyLabel
rows={3}
value={entry}
onChange={(event) => setEntry(event.target.value)}
placeholder={placeholder}
titlePlaceholder="Titel (optional)"
avatarProps={{ src: currentUserAvatar, name: currentUserName }}
submitButton={
<Button
type="button"
variant="primary"
color="indigo"
size="sm"
isLoading={isSubmitting}
disabled={!entry.trim() || isSubmitting}
onClick={() => void handleSubmit()}
leadingIcon={
<PaperAirplaneIcon aria-hidden="true" className="size-4" />
}
>
{submitLabel}
</Button>
}
/>
</div>
)}
{showSearch && items.length > 0 && (
<div className="mb-4 flex justify-end">
<div className="relative w-80 max-w-full">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
type="search"
value={activeSearchQuery}
onChange={(event) => updateSearchQuery(event.target.value)}
placeholder="Journal durchsuchen..."
aria-label="Journal durchsuchen"
className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
)}
{items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50/70 px-6 py-10 text-center dark:border-white/15 dark:bg-white/[0.03]">
<div className="mx-auto flex size-11 items-center justify-center rounded-2xl bg-white text-gray-400 shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-500 dark:ring-white/10">
<ChatBubbleLeftEllipsisIcon
aria-hidden="true"
className="size-6"
/>
</div>
<p className="mt-3 text-sm font-semibold text-gray-900 dark:text-white">
Noch keine Aktivitäten
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{emptyText}
</p>
</div>
) : filteredItems.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50/70 px-6 py-10 text-center dark:border-white/15 dark:bg-white/[0.03]">
<div className="mx-auto flex size-11 items-center justify-center rounded-2xl bg-white text-gray-400 shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-500 dark:ring-white/10">
<MagnifyingGlassIcon
aria-hidden="true"
className="size-6"
/>
</div>
<p className="mt-3 text-sm font-semibold text-gray-900 dark:text-white">
Keine Treffer
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Es wurden keine Einträge zu {activeSearchQuery.trim()}" gefunden.
</p>
</div>
) : (
<div className="flow-root">
<ul role="list" className="space-y-4">
{filteredItems.map((item, itemIndex) => {
const personName = getItemPersonName(item)
const imageUrl = getItemImageUrl(item)
const dateTime = getItemDateTime(item)
const Icon = getJournalIcon(item)
const itemType = getJournalType(item)
const isManualEntry = item.type === 'journal' || item.type === 'comment'
const changes = item.changes ?? []
const hasChanges = !isManualEntry && changes.length > 0
return (
<li key={item.id} className="relative">
{itemIndex !== filteredItems.length - 1 && (
<span
aria-hidden="true"
className="absolute top-10 bottom-[-1rem] left-5 w-px bg-gradient-to-b from-gray-300 to-gray-200 dark:from-white/20 dark:to-white/5"
/>
)}
<div className="relative flex items-start gap-3 sm:gap-4">
<div className="relative z-10 size-10 shrink-0">
<Avatar
src={imageUrl}
name={personName}
size={10}
className="ring-4 ring-white dark:ring-gray-900"
/>
<span
className={classNames(
getJournalIconBackground(item),
'absolute -right-1 -bottom-1 z-20 flex size-5 items-center justify-center rounded-full shadow-sm ring-2 ring-white dark:ring-gray-900',
)}
>
<Icon aria-hidden="true" className="size-3 text-white" />
</span>
</div>
<article className="min-w-0 flex-1 rounded-lg bg-white p-4 shadow-xs outline-1 -outline-offset-1 outline-gray-300 dark:bg-gray-900/70 dark:outline-white/10">
<header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{item.person?.href ? (
<a
href={item.person.href}
className="truncate text-sm font-semibold text-gray-950 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-300"
>
{personName}
</a>
) : (
<span className="truncate text-sm font-semibold text-gray-950 dark:text-white">
{personName}
</span>
)}
{hasChanges && (
<Badge
tone="indigo"
variant="border"
shape="circle"
size="small"
>
{changes.length}{' '}
{changes.length === 1 ? 'Änderung' : 'Änderungen'}
</Badge>
)}
{item.hasNotification && (
<NotificationDot
title="Neuer Journal-Eintrag"
label="Neuer Journal-Eintrag"
className="size-2"
/>
)}
</div>
{!isManualEntry && (
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
{getItemDescription(item)}
</p>
)}
</div>
<time
dateTime={dateTime}
className="inline-flex w-fit shrink-0 items-center gap-1.5 rounded-full bg-gray-50 px-2.5 py-1 text-xs font-medium whitespace-nowrap text-gray-500 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:text-gray-400 dark:ring-white/10"
>
<ClockIcon aria-hidden="true" className="size-3.5" />
{item.date}
</time>
</header>
{isManualEntry && editingItemId === item.id ? (
<div className="mt-4 rounded-xl bg-gray-50/70 p-3 ring-1 ring-gray-200 ring-inset dark:bg-white/[0.03] dark:ring-white/10">
<Textarea
label="Journal-Eintrag bearbeiten"
srOnlyLabel
rows={3}
value={editEntry}
onChange={(event) => setEditEntry(event.target.value)}
className="min-h-24"
/>
<div className="mt-3 flex justify-end gap-x-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isSubmitting}
onClick={cancelEditing}
>
Abbrechen
</Button>
<Button
type="button"
variant="primary"
color="indigo"
size="sm"
isLoading={isSubmitting}
disabled={!editEntry.trim() || isSubmitting}
onClick={() => void handleUpdate(item.id)}
>
Speichern
</Button>
</div>
</div>
) : isManualEntry ? (
<div className="mt-4 rounded-xl bg-gray-50/80 p-3.5 text-sm text-gray-700 ring-1 ring-gray-200 ring-inset dark:bg-white/[0.04] dark:text-gray-200 dark:ring-white/10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1 whitespace-pre-wrap break-words leading-6">
{getItemDescription(item)}
</div>
{item.canEdit && onEntryUpdate && (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
disabled={isSubmitting}
onClick={() => startEditing(item)}
leadingIcon={
<PencilSquareIcon
aria-hidden="true"
className="size-4"
/>
}
>
Bearbeiten
</Button>
)}
</div>
{item.editedDate && (
<div className="mt-3 flex items-center gap-x-1.5 border-t border-gray-200/70 pt-3 text-[11px] leading-4 text-gray-400 dark:border-white/10 dark:text-gray-500">
<PencilSquareIcon aria-hidden="true" className="size-3.5" />
<span>
Zuletzt bearbeitet:{' '}
<time dateTime={getItemEditedDateTime(item)}>
{item.editedDate}
</time>
</span>
</div>
)}
</div>
) : hasChanges ? (
<div className="mt-4 space-y-3">
{changes.map((change, changeIndex) => (
<section
key={getChangeKey(change, changeIndex)}
className="rounded-xl border-1 border-gray-200/70 bg-gray-50/70 p-3.5 dark:border-indigo-500/60 dark:bg-white/[0.03]"
>
<h5 className="flex items-center gap-1.5 text-xs font-semibold text-gray-900 dark:text-white">
<PencilSquareIcon
aria-hidden="true"
className="size-3.5 text-indigo-500 dark:text-indigo-400"
/>
{change.label}
</h5>
<div className="mt-2.5 grid grid-cols-1 items-stretch gap-2 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<div className="min-w-0 rounded-lg bg-white p-3 ring-1 ring-gray-200 ring-inset dark:bg-gray-950/30 dark:ring-white/10">
<div className="text-[10px] font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
Vorher
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-gray-500 line-through dark:text-gray-400">
{getChangeValue(change.oldValue)}
</div>
</div>
<div className="flex items-center justify-center">
<span className="flex size-7 items-center justify-center rounded-full bg-white text-indigo-500 shadow-xs ring-1 ring-indigo-200 dark:bg-white/5 dark:text-indigo-400 dark:ring-indigo-400/30">
<ArrowRightIcon
aria-hidden="true"
className="size-3.5 rotate-90 lg:rotate-0"
/>
</span>
</div>
<div className="min-w-0 rounded-lg bg-indigo-50 p-3 ring-1 ring-indigo-300 dark:bg-indigo-500/15 dark:ring-indigo-400/40">
<div className="text-[10px] font-semibold tracking-wider text-indigo-600 uppercase dark:text-indigo-300">
Nachher
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm font-semibold text-gray-950 dark:text-white">
{getChangeValue(change.newValue)}
</div>
</div>
</div>
</section>
))}
</div>
) : null}
</article>
</div>
</li>
)
})}
</ul>
</div>
)}
</div>
)
}