feat(korrespondenz): redesign ConversationTimeline to correspondence log cards
Replace chat-bubble layout with compact log rows featuring direction arrows, colored left borders (navy = outbound, mint = inbound), year dividers with per-year counts, asymmetry bar for bilateral mode, single-person other-party label, and encodeURIComponent-based new-doc link. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { conv_no_party } from '$lib/messages-extra';
|
||||
|
||||
let {
|
||||
documents,
|
||||
senderId,
|
||||
receiverId,
|
||||
canWrite
|
||||
}: {
|
||||
interface Props {
|
||||
documents: {
|
||||
id: string;
|
||||
title?: string;
|
||||
@@ -16,19 +12,16 @@ let {
|
||||
location?: string;
|
||||
status: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||
}[];
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
receiverId?: string;
|
||||
canWrite: boolean;
|
||||
} = $props();
|
||||
senderName?: string;
|
||||
receiverName?: string;
|
||||
}
|
||||
|
||||
const documentYears = $derived(
|
||||
documents
|
||||
.map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
|
||||
.filter((y): y is number => y !== null)
|
||||
);
|
||||
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
|
||||
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
|
||||
let { documents, senderId, receiverId, canWrite, senderName, receiverName }: Props = $props();
|
||||
|
||||
const enrichedDocuments = $derived(
|
||||
documents.map((doc, i) => {
|
||||
@@ -37,128 +30,149 @@ const enrichedDocuments = $derived(
|
||||
i > 0 && documents[i - 1].documentDate
|
||||
? new Date(documents[i - 1].documentDate!).getFullYear()
|
||||
: null;
|
||||
return { doc, year, showYearDivider: year !== null && year !== prevYear };
|
||||
const isOut = doc.sender?.id === senderId;
|
||||
return { doc, year, showYearDivider: year !== null && year !== prevYear, isOut };
|
||||
})
|
||||
);
|
||||
|
||||
const countsByYear = $derived(
|
||||
documents.reduce((acc, d) => {
|
||||
if (d.documentDate) {
|
||||
const y = new Date(d.documentDate).getFullYear();
|
||||
acc.set(y, (acc.get(y) ?? 0) + 1);
|
||||
}
|
||||
return acc;
|
||||
}, new Map<number, number>())
|
||||
);
|
||||
|
||||
const outCount = $derived(documents.filter((d) => d.sender?.id === senderId).length);
|
||||
const inCount = $derived(documents.length - outCount);
|
||||
const outPct = $derived(documents.length > 0 ? (outCount / documents.length) * 100 : 0);
|
||||
|
||||
const isBilateral = $derived(!!senderId && !!receiverId);
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PLACEHOLDER: 'bg-yellow-400',
|
||||
UPLOADED: 'bg-green-500',
|
||||
TRANSCRIBED: 'bg-blue-500',
|
||||
REVIEWED: 'bg-purple-500',
|
||||
ARCHIVED: 'bg-gray-500'
|
||||
};
|
||||
return map[status] ?? 'bg-gray-300';
|
||||
}
|
||||
|
||||
function otherPartyName(doc: (typeof documents)[number]): string {
|
||||
if (doc.sender?.id === senderId) {
|
||||
const r = doc.receivers?.[0];
|
||||
return r ? `${r.firstName} ${r.lastName}` : conv_no_party();
|
||||
}
|
||||
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : conv_no_party();
|
||||
}
|
||||
|
||||
const newDocUrl = $derived(
|
||||
`/documents/new?senderId=${encodeURIComponent(senderId)}${receiverId ? `&receiverId=${encodeURIComponent(receiverId)}` : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Summary bar -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
{#if yearFrom !== null && yearTo !== null}
|
||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
||||
{m.conv_summary({ count: documents.length, yearFrom, yearTo })}
|
||||
</p>
|
||||
{:else}
|
||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
||||
{documents.length}
|
||||
</p>
|
||||
{/if}
|
||||
{#if canWrite}
|
||||
<a
|
||||
data-testid="conv-new-doc-link"
|
||||
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
{m.conv_new_doc_link()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- CHAT CONTAINER -->
|
||||
<div class="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
<!-- Decoration: Central Timeline Line -->
|
||||
{#if isBilateral && documents.length > 0}
|
||||
<div
|
||||
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
|
||||
></div>
|
||||
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="relative z-10 flex flex-col gap-4">
|
||||
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
|
||||
{#if showYearDivider}
|
||||
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
|
||||
<div class="flex-grow border-t border-line"></div>
|
||||
<span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase"
|
||||
>{year}</span
|
||||
>
|
||||
<div class="flex-grow border-t border-line"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{@const isRight = doc.sender?.id === senderId}
|
||||
|
||||
<!-- Message Row -->
|
||||
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
|
||||
<!-- Bubble Group -->
|
||||
<div
|
||||
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
|
||||
? 'flex-row-reverse'
|
||||
: 'flex-row'}"
|
||||
>
|
||||
<!-- AVATAR -->
|
||||
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
|
||||
{isRight
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink'}"
|
||||
>
|
||||
{#if doc.sender}
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
{:else}
|
||||
?
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BUBBLE CARD -->
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
|
||||
{isRight
|
||||
? 'rounded-br-none border-primary bg-primary text-primary-fg'
|
||||
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="mb-2 flex items-start justify-between gap-4">
|
||||
<h3
|
||||
class="font-serif text-sm leading-snug font-medium {isRight
|
||||
? 'text-primary-fg'
|
||||
: 'text-ink'}"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h3>
|
||||
|
||||
<!-- Status Dot -->
|
||||
<span
|
||||
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
|
||||
{doc.status === 'UPLOADED' ? 'bg-accent' : 'bg-yellow-400'}"
|
||||
title={doc.status}
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div
|
||||
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
|
||||
? 'text-primary-fg/70'
|
||||
: 'text-ink-2'}"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
{#if doc.location}
|
||||
<span class="flex items-center">
|
||||
• {doc.location}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
class="flex flex-col gap-1 border-b border-[#E8E4DF] bg-[#F7F5F2] px-[18px] py-2"
|
||||
role="img"
|
||||
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName ?? ''}, {inCount} von {receiverName ?? ''}"
|
||||
>
|
||||
<div class="flex justify-between text-[10px] font-bold">
|
||||
<span class="text-[#002850]">{outCount} von {senderName} →</span>
|
||||
<span class="text-[#0F5755]">{inCount} von {receiverName} ←</span>
|
||||
</div>
|
||||
<div class="flex h-[5px] overflow-hidden rounded-full bg-[#E0DDD6]">
|
||||
<div class="h-full bg-[#002850] transition-all" style="width: {outPct}%"></div>
|
||||
<div class="h-full bg-[#A6DAD8] transition-all" style="width: {100 - outPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-hidden rounded-sm border border-[#E0DDD6] bg-white">
|
||||
{#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)}
|
||||
{#if showYearDivider && year !== null}
|
||||
<div
|
||||
data-testid="year-divider"
|
||||
class="flex items-baseline gap-2 border-t-2 border-b border-[#C8C4BE] border-[#D8D4CE] bg-[#F0EDE6] px-[14px] py-[6px]"
|
||||
>
|
||||
<span class="text-base font-black tracking-tight text-[#002850]">{year}</span>
|
||||
<span class="text-xs font-bold text-[#AAA]">{countsByYear.get(year) ?? 0} Briefe</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label="{doc.title || doc.originalFilename}, {doc.documentDate
|
||||
? formatDate(doc.documentDate)
|
||||
: ''}"
|
||||
class="group flex min-h-[44px] cursor-pointer items-start gap-[9px] border-b border-l-[3px] border-[#EDEBE4] px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-[#F7F5F2]"
|
||||
class:border-l-[#002850]={isOut}
|
||||
class:border-l-[#A6DAD8]={!isOut}
|
||||
>
|
||||
<span
|
||||
class="w-[14px] shrink-0 pt-[1px] text-xs font-black"
|
||||
class:text-[#002850]={isOut}
|
||||
class:text-[#0F5755]={!isOut}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isOut ? '→' : '←'}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-[2px] truncate text-sm font-bold text-[#0D2240]">
|
||||
{doc.title || doc.originalFilename}
|
||||
</div>
|
||||
<div class="flex items-center gap-[5px] text-xs text-[#888]">
|
||||
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
|
||||
{#if doc.location}
|
||||
<span class="text-[#D1CCC8]">·</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
{#if !receiverId}
|
||||
<span class="text-[#D1CCC8]">·</span>
|
||||
<span>{otherPartyName(doc)}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="ml-[3px] h-[6px] w-[6px] shrink-0 rounded-full {statusDotClass(doc.status)}"
|
||||
title={doc.status}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="shrink-0 text-[#888] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true">›</span
|
||||
>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if canWrite}
|
||||
<div class="flex justify-end border-t border-[#E8E4DF] px-[14px] py-[6px]">
|
||||
<a
|
||||
href={newDocUrl}
|
||||
data-testid="conv-new-doc-link"
|
||||
class="inline-flex items-center gap-1 text-xs font-bold text-[#002850]/50 transition-colors hover:text-[#002850]"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{m.conv_new_doc_link()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user