refactor(ui): rename route /korrespondenz → /briefwechsel

Update all internal links (AppNav, CoCorrespondentsList, goto) to the
new URL. No redirect needed — no production URLs exist yet.

Refs: #179

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-06 19:22:22 +02:00
parent a863f8baad
commit a9228d156f
13 changed files with 6 additions and 6 deletions

View File

@@ -0,0 +1,82 @@
import { error } from '@sveltejs/kit';
import type { components } from '$lib/generated/api';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ url, fetch, locals }) {
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const dir = url.searchParams.get('dir') || 'DESC';
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
const api = createApiClient(fetch);
let documents: components['schemas']['Document'][] = [];
let senderName = '';
let receiverName = '';
const requests: Promise<void>[] = [];
if (senderId) {
requests.push(
api
.GET('/api/documents/conversation', {
params: {
query: {
senderId,
receiverId: receiverId || undefined,
dir,
from: from || undefined,
to: to || undefined
}
}
})
.then((result) => {
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
documents = result.data ?? [];
})
);
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => {
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const p = result.data as { firstName: string; lastName: string } | undefined;
if (p) senderName = `${p.firstName} ${p.lastName}`;
})
);
}
if (receiverId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => {
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const p = result.data as { firstName: string; lastName: string } | undefined;
if (p) receiverName = `${p.firstName} ${p.lastName}`;
})
);
}
await Promise.all(requests);
return {
documents,
canWrite,
initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir }
};
}

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte';
import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte';
import SinglePersonHintBar from './SinglePersonHintBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte';
import CorrespondenzEmptyState from './CorrespondenzEmptyState.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
// Filter values are local $state so swapPersons/toggleSort can mutate them before goto.
// They are initialised once from server data and never re-synced — navigation replaces
// the page component, so each load gets a fresh init.
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Names are pure reads of server data — no local mutation needed.
const senderName = $derived(data.initialValues.senderName);
const receiverName = $derived(data.initialValues.receiverName);
// Side-effect only: persist the resolved sender to localStorage once the name is available.
$effect(() => {
if (data.filters.senderId && data.initialValues.senderName) {
persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
}
});
const isSinglePerson = $derived(!!senderId && !receiverId);
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
const MAX_RECENT = 5;
function persistRecentPerson(id: string, name: string) {
if (!id) return;
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
const existing: { id: string; name: string }[] = raw ? JSON.parse(raw) : [];
const filtered = existing.filter((p) => p.id !== id);
const updated = [{ id, name }, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
} catch {
// localStorage unavailable — silently ignore
}
}
function applyFilters() {
const params = new SvelteURLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`/briefwechsel?${params.toString()}`, { keepFocus: true });
}
function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
function swapPersons() {
const tmp = senderId;
senderId = receiverId;
receiverId = tmp;
applyFilters();
}
function selectPerson(id: string) {
if (!id) {
document.querySelector<HTMLInputElement>('#senderId-search')?.focus();
return;
}
senderId = id;
receiverId = '';
applyFilters();
}
</script>
<!-- Strips — pulled up to negate main's py-6 top padding so they sit flush -->
<div class="-mt-6">
<!-- Strip: Row 1 — full width, no container -->
<CorrespondenzPersonBar
bind:senderId={senderId}
bind:receiverId={receiverId}
initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName}
onapplyFilters={applyFilters}
onswapPersons={swapPersons}
/>
<!-- Strip: Row 2 — full width -->
<CorrespondenzFilterControls
senderId={senderId}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
documentCount={data.documents.length}
onapplyFilters={applyFilters}
ontoggleSort={toggleSort}
/>
<!-- Single-person hint bar -->
{#if isSinglePerson}
<SinglePersonHintBar
senderName={senderName}
fromDate={fromDate || undefined}
toDate={toDate || undefined}
sortDir={sortDir}
/>
{/if}
</div>
<!-- Content area with padding -->
<div class="px-[18px] py-[14px]">
{#if !senderId}
<CorrespondenzEmptyState onSelectPerson={selectPerson} />
{:else if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-sm"
>
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
</div>
{:else}
<ConversationTimeline
documents={data.documents}
senderId={senderId}
receiverId={receiverId}
canWrite={data.canWrite}
senderName={senderName}
receiverName={receiverName}
/>
{/if}
</div>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
senderId = $bindable(''),
receiverId = $bindable(''),
fromDate = $bindable(''),
toDate = $bindable(''),
sortDir = $bindable('DESC'),
initialSenderName = '',
initialReceiverName = '',
onapplyFilters,
ontoggleSort,
onswapPersons
}: {
senderId?: string;
receiverId?: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
initialSenderName?: string;
initialReceiverName?: string;
onapplyFilters: () => void;
ontoggleSort: () => void;
onswapPersons: () => void;
} = $props();
</script>
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.conv_label_person_a()}
bind:value={senderId}
initialName={initialSenderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
<!-- Swap button -->
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
<button
data-testid="conv-swap-btn"
onclick={onswapPersons}
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
title={m.conv_swap_btn()}
>
<svg
class="h-4 w-4 flex-shrink-0 md:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
<span class="md:hidden">{m.conv_swap_btn()}</span>
</button>
</div>
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={initialReceiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
</div>
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_from()}</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<!-- Date To -->
<div>
<label for="dateTo" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_to()}</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
onclick={ontoggleSort}
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
<svg
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,180 @@
<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; lastName: string } | null;
receivers?: { id: string; firstName: string; lastName: 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.firstName} ${r.lastName}` : m.conv_no_party();
}
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : 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="text-primary">{outCount} von {shortSenderName}</span>
<span class="text-accent">{inCount} von {shortReceiverName}</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}
>
<span
class="w-[16px] shrink-0 text-sm font-black"
class:text-primary={isOut}
class:text-accent={!isOut}
aria-hidden="true"
>
{isOut ? '→' : '←'}
</span>
<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>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
interface Correspondent {
id: string;
firstName: string;
lastName: string;
}
interface Props {
correspondents: Correspondent[];
loading: boolean;
senderName: string;
onselect: (id: string) => void;
onclose: () => void;
}
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
function getOptionElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
}
function handleKeydown(event: KeyboardEvent, container: HTMLElement) {
const options = getOptionElements(container);
const focused = document.activeElement as HTMLElement;
const idx = options.indexOf(focused);
if (event.key === 'ArrowDown') {
event.preventDefault();
const next = options[idx + 1] ?? options[0];
next?.focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
const prev = options[idx - 1] ?? options[options.length - 1];
prev?.focus();
} else if (event.key === 'Escape') {
onclose();
}
}
function getInitials(person: Correspondent): string {
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
}
</script>
<div
use:clickOutside
onclickoutside={onclose}
role="listbox"
tabindex="-1"
aria-label={m.conv_suggestions_heading()}
class="absolute top-full right-0 left-0 z-30 mt-1 rounded-sm border border-line bg-surface shadow-lg"
onkeydown={(e) => handleKeydown(e, e.currentTarget as HTMLElement)}
>
<!-- Heading -->
<div class="px-3 pt-2 pb-1 text-[10px] font-bold tracking-widest text-ink-3 uppercase">
{m.conv_suggestions_heading()}
</div>
<!-- Correspondent rows -->
{#if !loading}
{#each correspondents as person (person.id)}
<div
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
onclick={() => onselect(person.id)}
onkeydown={(e) => e.key === 'Enter' && onselect(person.id)}
>
<!-- Avatar with initials -->
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
aria-hidden="true"
>
{getInitials(person)}
</span>
<!-- Svelte auto-escapes — do not use {@html} here. -->
{person.lastName}, {person.firstName}
</div>
{/each}
{/if}
<!-- Separator -->
<div class="mt-1 border-t border-line"></div>
<!-- "Alle Korrespondenten" row -->
<div
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
onclick={() => onselect('')}
onkeydown={(e) => e.key === 'Enter' && onselect('')}
>
{m.conv_suggestions_all_label({ name: senderName })}
</div>
</div>

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
interface RecentPerson {
id: string;
name: string;
}
interface Props {
onSelectPerson: (id: string) => void;
}
const { onSelectPerson }: Props = $props();
let recentPersons = $state<RecentPerson[]>([]);
onMount(() => {
try {
const raw = localStorage.getItem('korrespondenz_recent_persons');
if (raw) {
// Svelte auto-escapes firstName/lastName — do not use {@html} with these values
recentPersons = JSON.parse(raw) as RecentPerson[];
}
} catch {
recentPersons = [];
}
});
</script>
<div class="mx-auto flex max-w-lg flex-col items-center gap-5 py-12 text-center">
<!-- Icon circle -->
<div class="rounded-full bg-muted p-5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="text-primary"
aria-hidden="true"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M2 7l10 7 10-7" />
</svg>
</div>
<!-- Heading -->
<h2 class="font-serif text-xl font-black text-ink">{m.conv_empty_heading()}</h2>
<!-- Subtext -->
<p class="max-w-sm text-base text-ink-3">
{m.conv_empty_text()}
</p>
<!-- Search input placeholder (visual only — clicking focuses Person A typeahead above) -->
<button
type="button"
data-testid="conv-empty-search"
aria-label={m.conv_empty_search_placeholder()}
onclick={() => onSelectPerson('')}
class="flex h-10 w-full max-w-sm items-center rounded border border-line bg-muted px-4 text-sm text-ink-3 italic transition-colors hover:border-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2 shrink-0"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
{m.conv_empty_search_placeholder()}
</button>
<!-- Recent persons — only shown when localStorage has entries -->
{#if recentPersons.length > 0}
<!-- Divider -->
<div class="flex w-full max-w-sm items-center gap-2">
<div class="flex-1 border-t border-line"></div>
<span class="text-xs font-bold tracking-wider text-ink-3 uppercase">oder</span>
<div class="flex-1 border-t border-line"></div>
</div>
<div class="flex w-full max-w-sm flex-col items-center gap-3">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.conv_empty_recent_label()}
</span>
<div class="flex flex-wrap justify-center gap-2">
{#each recentPersons as person (person.id)}
<!-- TODO: allow clearing recent history -->
<button
type="button"
onclick={() => onSelectPerson(person.id)}
class="flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 text-sm font-bold text-ink transition-colors hover:border-primary hover:text-primary"
>
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-xs text-primary-fg"
aria-hidden="true"
>
{person.name.charAt(0).toUpperCase()}
</span>
{person.name}
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/components/DateInput.svelte';
interface Props {
senderId: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
documentCount?: number;
onapplyFilters: () => void;
ontoggleSort: () => void;
}
let {
senderId,
fromDate = $bindable(''),
toDate = $bindable(''),
sortDir = $bindable('DESC'),
documentCount,
onapplyFilters,
ontoggleSort
}: Props = $props();
let hasDateFilter = $derived(!!(fromDate || toDate));
let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
</script>
<div
class="flex items-center gap-[10px] border-b border-line bg-muted px-4 py-[5px] transition-opacity sm:px-[18px]"
class:opacity-40={!senderId}
class:pointer-events-none={!senderId}
aria-disabled={!senderId}
>
<!-- Period label -->
<span class="hidden text-xs font-bold tracking-wide text-ink-3 uppercase sm:block">
{m.conv_strip_period()}
</span>
<!-- From date -->
<DateInput
bind:value={fromDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_from_placeholder()}
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {fromDate ? 'border-primary' : 'border-line'}"
/>
<span class="text-xs text-ink-3"></span>
<!-- To date -->
<DateInput
bind:value={toDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_to_placeholder()}
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {toDate ? 'border-primary' : 'border-line'}"
/>
<!-- Document count -->
<span
data-testid="conv-strip-count"
class="ml-auto text-xs font-bold"
class:text-primary={hasDateFilter}
class:text-ink-3={!hasDateFilter}
>
{m.conv_letters_count({ count: documentCount ?? 0 })}
</span>
<!-- Sort button -->
<button
data-testid="conv-sort-btn"
type="button"
aria-label="Sortierung umkehren"
aria-pressed={sortDir === 'ASC'}
onclick={ontoggleSort}
class="flex h-8 min-h-[44px] items-center gap-1 rounded border px-3 text-xs font-bold"
class:border-primary={isActive}
class:text-primary={isActive}
class:border-line={!isActive}
class:text-ink-3={!isActive}
>
{#if sortDir === 'ASC'}
{m.conv_strip_sort_oldest()}
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="18 15 12 9 6 15" />
</svg>
{:else}
{m.conv_strip_sort_newest()}
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
{/if}
</button>
</div>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte';
interface Props {
senderId?: string;
receiverId?: string;
initialSenderName?: string;
initialReceiverName?: string;
onapplyFilters: () => void;
onswapPersons: () => void;
}
let {
senderId = $bindable(''),
receiverId = $bindable(''),
initialSenderName = '',
initialReceiverName = '',
onapplyFilters,
onswapPersons
}: Props = $props();
interface Correspondent {
id: string;
firstName: string;
lastName: string;
}
let swapVisible = $derived(!!(senderId && receiverId));
let showSuggestions = $state(false);
let correspondents = $state<Correspondent[]>([]);
let loadingCorrespondents = $state(false);
async function handleCorrespondentFocused() {
if (!senderId) return;
showSuggestions = true;
loadingCorrespondents = true;
try {
const res = await fetch(`/api/persons/${senderId}/correspondents`);
correspondents = res.ok ? await res.json() : [];
} catch {
correspondents = [];
} finally {
loadingCorrespondents = false;
}
}
function handleSuggestionSelect(id: string) {
receiverId = id;
showSuggestions = false;
onapplyFilters();
}
</script>
<div class="flex items-end gap-[9px] border-b border-line bg-surface px-4 py-[9px] sm:px-[18px]">
<!-- Person A -->
<div class="min-w-0 flex-1">
<PersonTypeahead
name="senderId"
label="Person"
bind:value={senderId}
initialName={initialSenderName}
compact={true}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
<!-- Swap button -->
<button
data-testid="conv-swap-btn"
type="button"
aria-label="Personen tauschen"
onclick={onswapPersons}
class="flex h-9 w-9 shrink-0 items-center justify-center rounded border border-line bg-surface text-ink-3 transition-colors hover:border-primary hover:text-primary"
class:opacity-0={!swapVisible}
class:pointer-events-none={!swapVisible}
tabindex={swapVisible ? 0 : -1}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M7 16V4m0 0L3 8m4-4l4 4" />
<path d="M17 8v12m0 0l4-4m-4 4l-4-4" />
</svg>
</button>
<!-- Korrespondent field -->
<div
class="relative min-w-0 flex-1"
class:[&_input]:border-dashed={!receiverId}
class:[&_input]:border-solid={!!receiverId}
class:[&_input]:bg-canvas={!receiverId}
>
<PersonTypeahead
name="receiverId"
label={receiverId ? 'Korrespondent' : 'Korrespondent — optional'}
bind:value={receiverId}
initialName={initialReceiverName}
compact={true}
placeholder="Alle Korrespondenten"
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => {
showSuggestions = false;
onapplyFilters();
}}
onfocused={handleCorrespondentFocused}
/>
{#if showSuggestions && senderId && !receiverId}
<CorrespondentSuggestionsDropdown
correspondents={correspondents}
loading={loadingCorrespondents}
senderName=""
onselect={handleSuggestionSelect}
onclose={() => (showSuggestions = false)}
/>
{/if}
</div>
</div>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
senderName: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
}
let { senderName, fromDate = '', toDate = '', sortDir = 'DESC' }: Props = $props();
let hasDateFilter = $derived(!!(fromDate || toDate));
let sortLabel = $derived(
sortDir === 'ASC' ? m.conv_strip_sort_oldest() : m.conv_strip_sort_newest()
);
let fromYear = $derived(fromDate ? fromDate.substring(0, 4) : '');
let toYear = $derived(toDate ? toDate.substring(0, 4) : '');
</script>
<div
class="flex items-center gap-[5px] border-b border-accent bg-accent-bg px-[18px] py-[6px] text-xs text-ink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="shrink-0"
>
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
{#if hasDateFilter}
<strong>{senderName}</strong>
<span>·</span>
<span>{fromYear}{toYear}</span>
<span>·</span>
<span>{sortLabel}</span>
{:else}
Alle Briefe von <strong>{senderName}</strong> — wähle einen Korrespondenten oben um einzugrenzen
{/if}
</div>

View File

@@ -0,0 +1,146 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+page.server';
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
vi.mock('$lib/errors', () => ({ getErrorMessage: (code: string) => code ?? 'Unknown error' }));
import { createApiClient } from '$lib/api.server';
const writeUser = { groups: [{ permissions: ['WRITE_ALL'] }] };
const readUser = { groups: [{ permissions: ['READ_ALL'] }] };
function makeUrl(params: Record<string, string> = {}): URL {
const url = new URL('http://x/korrespondenz');
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
return url;
}
function mockApi(calls: { ok: boolean; data?: unknown; status?: number }[]) {
const GET = vi.fn();
for (const call of calls) {
GET.mockResolvedValueOnce({
response: { ok: call.ok, status: call.status ?? (call.ok ? 200 : 500) },
data: call.data,
error: call.ok ? undefined : { code: 'INTERNAL_ERROR' }
});
}
vi.mocked(createApiClient).mockReturnValue({ GET } as ReturnType<typeof createApiClient>);
return GET;
}
beforeEach(() => vi.clearAllMocks());
// ─── No senderId ──────────────────────────────────────────────────────────────
describe('korrespondenz load — no senderId', () => {
it('returns empty documents without calling the conversation endpoint', async () => {
const GET = mockApi([]);
const result = await load({
url: makeUrl(),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
expect(result.documents).toEqual([]);
expect(GET).not.toHaveBeenCalled();
});
});
// ─── With senderId, no receiverId ────────────────────────────────────────────
describe('korrespondenz load — senderId set, no receiverId', () => {
it('calls the conversation endpoint and the sender person endpoint', async () => {
const docs = [{ id: 'd1', title: 'Testbrief' }];
const GET = mockApi([
{ ok: true, data: docs },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
]);
const result = await load({
url: makeUrl({ senderId: 'p1' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
expect(result.documents).toEqual(docs);
expect(result.initialValues.senderName).toBe('Hans Müller');
expect(result.initialValues.receiverName).toBe('');
expect(GET).toHaveBeenCalledTimes(2);
});
});
// ─── With senderId and receiverId ────────────────────────────────────────────
describe('korrespondenz load — senderId and receiverId set', () => {
it('calls conversation, sender person, and receiver person endpoints', async () => {
const GET = mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } },
{ ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } }
]);
const result = await load({
url: makeUrl({ senderId: 'p1', receiverId: 'p2' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
expect(result.initialValues.senderName).toBe('Hans Müller');
expect(result.initialValues.receiverName).toBe('Anna Schmidt');
expect(GET).toHaveBeenCalledTimes(3);
});
});
// ─── canWrite derivation ─────────────────────────────────────────────────────
describe('korrespondenz load — canWrite', () => {
it('derives canWrite true from WRITE_ALL permission', async () => {
mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
]);
const result = await load({
url: makeUrl({ senderId: 'p1' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: writeUser }
});
expect(result.canWrite).toBe(true);
});
it('derives canWrite false when user lacks WRITE_ALL', async () => {
mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
]);
const result = await load({
url: makeUrl({ senderId: 'p1' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
expect(result.canWrite).toBe(false);
});
});
// ─── Backend error propagation ────────────────────────────────────────────────
describe('korrespondenz load — backend error', () => {
it('throws when the conversation endpoint returns non-ok', async () => {
mockApi([
{ ok: false, status: 500 },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
]);
await expect(
load({
url: makeUrl({ senderId: 'p1' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
})
).rejects.toMatchObject({ status: 500 });
});
});

View File

@@ -0,0 +1,246 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(cleanup);
// ─── Test data ────────────────────────────────────────────────────────────────
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
};
const withSender = {
...baseData,
initialValues: { senderName: 'Hans Müller', receiverName: '' },
filters: { ...baseData.filters, senderId: 'p1' }
};
const withPersons = {
...baseData,
initialValues: { senderName: 'Hans Müller', receiverName: 'Anna Schmidt' },
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
};
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED' as const,
documentDate: '1923-04-12',
location: 'Berlin',
metadataComplete: false,
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [],
transcription: undefined,
filePath: undefined,
createdAt: '1923-04-12T00:00:00Z',
updatedAt: '1923-04-12T00:00:00Z',
...overrides
});
const withDocs = {
...withPersons,
documents: [makeDoc()]
};
// ─── Empty state (no senderId) ────────────────────────────────────────────────
describe('Korrespondenz page empty state', () => {
it('shows the search heading when no person is selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
});
it('shows the empty-search button', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('conv-empty-search')).toBeInTheDocument();
});
it('does not show the new document link when no person is selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
});
it('does not show a year divider when no person is selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('year-divider')).not.toBeInTheDocument();
});
});
// ─── Recent persons chips ─────────────────────────────────────────────────────
describe('Korrespondenz page recent persons', () => {
it('shows recent person chips from localStorage', async () => {
localStorage.setItem(
'korrespondenz_recent_persons',
JSON.stringify([{ id: 'r1', name: 'Clara Braun' }])
);
render(Page, { data: baseData });
await expect.element(page.getByText('Clara Braun')).toBeInTheDocument();
localStorage.removeItem('korrespondenz_recent_persons');
});
it('does not crash when localStorage contains corrupt JSON', async () => {
localStorage.setItem('korrespondenz_recent_persons', '}{not valid json');
render(Page, { data: baseData });
// Empty state heading is still shown — no chip list crash
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
localStorage.removeItem('korrespondenz_recent_persons');
});
});
// ─── Single-person hint bar ───────────────────────────────────────────────────
describe('Korrespondenz page single-person hint bar', () => {
it('shows hint bar when only senderId is set', async () => {
render(Page, { data: withSender });
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument();
});
it('does not show hint bar when both persons are set', async () => {
render(Page, { data: { ...withPersons, documents: [makeDoc()] } });
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).not.toBeInTheDocument();
});
it('does not show hint bar when no person is set', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Alle Briefe von/i)).not.toBeInTheDocument();
});
});
// ─── Filter controls disabled state ──────────────────────────────────────────
describe('Korrespondenz page filter strip Row 2 disabled state', () => {
it('renders filter controls with aria-disabled when no senderId', async () => {
render(Page, { data: baseData });
const strip = document.querySelector('[aria-disabled="true"]');
expect(strip).not.toBeNull();
});
it('filter controls are not aria-disabled when senderId is set', async () => {
render(Page, { data: withSender });
const strip = document.querySelector('[aria-disabled="false"]');
expect(strip).not.toBeNull();
});
});
// ─── Strip letter count ───────────────────────────────────────────────────────
describe('Korrespondenz page strip letter count', () => {
it('shows 0 Briefe when senderId is set but no documents', async () => {
render(Page, { data: withSender });
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe');
});
it('shows correct count when documents are loaded', async () => {
render(Page, { data: { ...withPersons, documents: [makeDoc()] } });
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('1 Briefe');
});
});
// ─── No results ───────────────────────────────────────────────────────────────
describe('Korrespondenz page no results', () => {
it('shows "no documents found" when a person is selected but there are no documents', async () => {
render(Page, { data: withSender });
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
});
});
// ─── Swap button ──────────────────────────────────────────────────────────────
describe('Korrespondenz page swap button', () => {
it('swap button is invisible when only one person is set', async () => {
render(Page, { data: withSender });
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
expect(btn).not.toBeNull();
// opacity-0 is applied via class when swapVisible is false
expect(btn!.className).toMatch(/opacity-0/);
});
it('swap button is visible when both persons are set', async () => {
render(Page, { data: withPersons });
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
expect(btn).not.toBeNull();
expect(btn!.className).not.toMatch(/opacity-0/);
});
it('calls goto with swapped sender and receiver when clicked', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
render(Page, { data: withPersons });
document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]')!.click();
expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
});
});
// ─── Year dividers ────────────────────────────────────────────────────────────
describe('Korrespondenz page year dividers', () => {
it('renders a year divider for the first document', async () => {
render(Page, { data: withDocs });
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
});
it('renders a divider for each new year in the document list', async () => {
const data = {
...withPersons,
documents: [
makeDoc({ documentDate: '1923-04-12' }),
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
]
};
render(Page, { data });
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965');
});
it('does not render a second divider for documents from the same year', async () => {
const data = {
...withPersons,
documents: [
makeDoc({ documentDate: '1923-04-12' }),
makeDoc({ id: 'd2', documentDate: '1923-09-01' })
]
};
render(Page, { data });
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
});
});
// ─── New document link ────────────────────────────────────────────────────────
describe('Korrespondenz page new document link', () => {
it('shows the link with correct href for a write user (bilateral)', async () => {
render(Page, { data: { ...withDocs, canWrite: true } });
const link = page.getByTestId('conv-new-doc-link');
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1'));
await expect.element(link).toHaveAttribute('href', expect.stringContaining('receiverId=p2'));
});
it('shows the link with correct href for single-person mode', async () => {
render(Page, { data: { ...withSender, documents: [makeDoc()], canWrite: true } });
const link = page.getByTestId('conv-new-doc-link');
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1'));
await expect.element(link).not.toHaveAttribute('href', expect.stringContaining('receiverId'));
});
it('hides the link for a read-only user', async () => {
render(Page, { data: { ...withDocs, canWrite: false } });
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
});
});