Files
familienarchiv/frontend/src/routes/briefwechsel/ConversationTimeline.svelte
Marcel f11d8a38ed feat(frontend): replace all name concatenation with displayName
- Add displayName default method to PersonSummaryDTO
- Update native SQL queries to include title, person_type columns
- Add getInitials() utility to personFormat.ts
- Update abbreviateName/abbreviateCompact for nullable firstName
- Replace firstName+lastName concatenation with displayName in all
  person-displaying components and server load files
- Regenerate API types with displayName on Person and PersonSummaryDTO

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:22:30 +02:00

202 lines
6.2 KiB
Svelte
Raw 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.
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
interface Props {
documents: {
id: string;
title?: string;
originalFilename: string;
documentDate?: string;
location?: string;
status: string;
sender?: {
id: string;
firstName?: string | null;
lastName: string;
displayName: string;
} | null;
receivers?: { id: string; firstName?: string | null; lastName: string; displayName: string }[];
}[];
senderId: string;
receiverId?: string;
canWrite: boolean;
senderName?: string;
receiverName?: string;
}
let { documents, senderId, receiverId, canWrite, senderName, receiverName }: Props = $props();
const enrichedDocuments = $derived(
documents.map((doc, i) => {
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
const prevYear =
i > 0 && documents[i - 1].documentDate
? new Date(documents[i - 1].documentDate!).getFullYear()
: null;
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);
const shortSenderName = $derived(senderName?.split(' ')[0] ?? senderName ?? '');
const shortReceiverName = $derived(receiverName?.split(' ')[0] ?? receiverName ?? '');
function statusDotClass(status: string): string {
const map: Record<string, string> = {
PLACEHOLDER: 'bg-brand-sand',
UPLOADED: 'bg-brand-mint',
TRANSCRIBED: 'bg-brand-mint',
REVIEWED: 'bg-brand-navy/70',
ARCHIVED: 'bg-brand-navy'
};
return map[status] ?? 'bg-brand-sand';
}
function otherPartyName(doc: (typeof documents)[number]): string {
if (doc.sender?.id === senderId) {
const r = doc.receivers?.[0];
return r ? r.displayName : m.conv_no_party();
}
return doc.sender ? doc.sender.displayName : m.conv_no_party();
}
const newDocUrl = $derived(
`/documents/new?senderId=${encodeURIComponent(senderId)}${receiverId ? `&receiverId=${encodeURIComponent(receiverId)}` : ''}`
);
</script>
{#if isBilateral && documents.length > 0}
<div
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
role="img"
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName ?? ''}, {inCount} von {receiverName ?? ''}"
>
<div class="flex justify-between text-sm font-bold">
<span class="inline-flex items-center gap-1 text-primary"
>{outCount} von {shortSenderName}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="inline h-3.5 w-3.5 opacity-60"
/></span
>
<span class="inline-flex items-center gap-1 text-accent"
>{inCount} von {shortReceiverName}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="inline h-3.5 w-3.5 opacity-60"
/></span
>
</div>
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
<div class="h-full bg-primary transition-all" style="width: {outPct}%"></div>
<div class="h-full bg-accent transition-all" style="width: {100 - outPct}%"></div>
</div>
</div>
{/if}
<div class="overflow-hidden rounded-sm border border-line bg-surface">
{#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)}
{#if showYearDivider && year !== null}
<div
data-testid="year-divider"
class="flex items-baseline gap-3 border-t-2 border-b border-line bg-muted px-[14px] py-[8px]"
>
<span class="text-2xl font-black tracking-tight text-primary">{year}</span>
<span class="text-sm font-bold text-ink-3">{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-center gap-[9px] border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-muted"
class:border-l-primary={isOut}
class:border-l-accent={!isOut}
>
<img
src={isOut
? '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg'
: '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg'}
alt=""
aria-hidden="true"
class="h-4 w-4 shrink-0 opacity-60"
/>
<div class="min-w-0 flex-1">
<div class="mb-[2px] truncate text-sm font-bold text-ink">
{doc.title || doc.originalFilename}
</div>
<div class="flex items-center gap-[5px] text-sm text-ink-3">
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
{#if doc.location}
<span class="text-line">·</span>
<span>{doc.location}</span>
{/if}
{#if !receiverId}
<span class="text-line">·</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-sm text-ink-3 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"></span
>
</a>
{/each}
{#if canWrite}
<div class="flex justify-end border-t border-line 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-primary/50 transition-colors hover:text-primary"
>
<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>