- 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>
202 lines
6.2 KiB
Svelte
202 lines
6.2 KiB
Svelte
<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>
|