573 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|