Compare commits
10 Commits
3addc72693
...
9d6c7b8605
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6c7b8605 | ||
|
|
56d79c919e | ||
|
|
3318b5f1c6 | ||
|
|
71eaca9495 | ||
|
|
a3a7af123d | ||
|
|
5fd7e41492 | ||
|
|
0387e9f428 | ||
|
|
49f6b0a8c7 | ||
|
|
1b95d9472b | ||
|
|
4f5f8255a1 |
114
frontend/e2e/korrespondenz.spec.ts
Normal file
114
frontend/e2e/korrespondenz.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Korrespondenz – empty state', () => {
|
||||
test('shows the search heading when no person is selected', async ({ page }) => {
|
||||
await page.goto('/korrespondenz');
|
||||
await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' });
|
||||
});
|
||||
|
||||
test('nav link goes to /korrespondenz', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Click the nav link (desktop text or mobile icon)
|
||||
const navLink = page.getByRole('link', { name: /Korrespondenz/i }).first();
|
||||
await navLink.click();
|
||||
await expect(page).toHaveURL(/\/korrespondenz/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Korrespondenz – single-person mode', () => {
|
||||
test('shows hint bar and documents when navigated with senderId', async ({ page }) => {
|
||||
// Get a real person ID from the persons list
|
||||
await page.goto('/persons');
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
|
||||
// Extract the person ID from the URL
|
||||
const personId = page.url().split('/persons/')[1].split('?')[0];
|
||||
|
||||
// Navigate to korrespondenz in single-person mode
|
||||
await page.goto(`/korrespondenz?senderId=${personId}`);
|
||||
|
||||
// Hint bar should be visible
|
||||
await expect(page.getByText(/Alle Briefe von/i)).toBeVisible();
|
||||
|
||||
// Filter controls should be active (not dimmed)
|
||||
const filterStrip = page.locator('[aria-disabled="false"]').first();
|
||||
await expect(filterStrip).toBeAttached();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' });
|
||||
});
|
||||
|
||||
test('sort toggle changes URL direction param', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
const personId = page.url().split('/persons/')[1].split('?')[0];
|
||||
|
||||
await page.goto(`/korrespondenz?senderId=${personId}&dir=DESC`);
|
||||
await page.getByTestId('conv-sort-btn').click();
|
||||
|
||||
await expect(page).toHaveURL(/dir=ASC/);
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-sort-asc.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Korrespondenz – bilateral mode', () => {
|
||||
test('shows asymmetry bar when both persons have shared documents', async ({ page }) => {
|
||||
// Navigate to a person then follow a co-correspondent suggestion if available
|
||||
await page.goto('/persons');
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
const senderId = page.url().split('/persons/')[1].split('?')[0];
|
||||
|
||||
// Try to find a co-correspondent link from the person detail page
|
||||
const corrLink = page
|
||||
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
|
||||
.first();
|
||||
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await corrLink.click();
|
||||
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
|
||||
|
||||
// Hint bar should NOT be shown in bilateral mode
|
||||
await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' });
|
||||
} else {
|
||||
// No bilateral data available for this person — skip with a note
|
||||
test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('swap button swaps sender and receiver in URL', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
const senderId = page.url().split('/persons/')[1].split('?')[0];
|
||||
|
||||
const corrLink = page
|
||||
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
|
||||
.first();
|
||||
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
const href = await corrLink.getAttribute('href');
|
||||
await corrLink.click();
|
||||
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
|
||||
|
||||
// Extract original receiverId from the href
|
||||
const url = new URL(href!, 'http://x');
|
||||
const originalReceiverId = url.searchParams.get('receiverId')!;
|
||||
|
||||
// Click swap
|
||||
await page.getByTestId('conv-swap-btn').click();
|
||||
|
||||
// After swap the former receiver is now senderId
|
||||
await expect(page).toHaveURL(new RegExp(`senderId=${originalReceiverId}`));
|
||||
await page.screenshot({ path: 'test-results/e2e/korrespondenz-swapped.png' });
|
||||
} else {
|
||||
test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
206
frontend/src/lib/components/DateInput.svelte.spec.ts
Normal file
206
frontend/src/lib/components/DateInput.svelte.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DateInput from './DateInput.svelte';
|
||||
|
||||
/** Wait one macrotask so Svelte can flush reactive DOM updates. */
|
||||
const domFlush = () => new Promise<void>((r) => setTimeout(r, 50));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – rendering', () => {
|
||||
it('renders a text input with inputmode=numeric and maxlength=10', async () => {
|
||||
render(DateInput, {});
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toBeInTheDocument();
|
||||
await expect.element(input).toHaveAttribute('inputmode', 'numeric');
|
||||
await expect.element(input).toHaveAttribute('maxlength', '10');
|
||||
});
|
||||
|
||||
it('has default placeholder from paraglide', async () => {
|
||||
render(DateInput, {});
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveAttribute('placeholder', 'TT.MM.JJJJ');
|
||||
});
|
||||
|
||||
it('accepts a custom placeholder', async () => {
|
||||
render(DateInput, { placeholder: 'Geburtsdatum' });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveAttribute('placeholder', 'Geburtsdatum');
|
||||
});
|
||||
|
||||
it('passes id prop to the input', async () => {
|
||||
render(DateInput, { id: 'my-date' });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveAttribute('id', 'my-date');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Init from value ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – init from value', () => {
|
||||
it('displays ISO value in German format on mount', async () => {
|
||||
render(DateInput, { value: '2024-12-20' });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveValue('20.12.2024');
|
||||
});
|
||||
|
||||
it('starts empty and error-free when no value is given', async () => {
|
||||
let errorMessage: string | null = null;
|
||||
render(DateInput, {
|
||||
get errorMessage() {
|
||||
return errorMessage;
|
||||
},
|
||||
set errorMessage(v) {
|
||||
errorMessage = v;
|
||||
}
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveValue('');
|
||||
expect(errorMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Typing valid date ────────────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – typing a valid date', () => {
|
||||
it('auto-formats to DD.MM.YYYY and updates value to ISO', async () => {
|
||||
let value = '';
|
||||
let errorMessage: string | null = null;
|
||||
render(DateInput, {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v) {
|
||||
value = v;
|
||||
},
|
||||
get errorMessage() {
|
||||
return errorMessage;
|
||||
},
|
||||
set errorMessage(v) {
|
||||
errorMessage = v;
|
||||
}
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('20122024');
|
||||
await expect.element(input).toHaveValue('20.12.2024');
|
||||
expect(value).toBe('2024-12-20');
|
||||
expect(errorMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Typing invalid month ─────────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – typing a date with invalid month', () => {
|
||||
it('sets errorMessage and clears value when month > 12', async () => {
|
||||
let value = '';
|
||||
let errorMessage: string | null = null;
|
||||
render(DateInput, {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v) {
|
||||
value = v;
|
||||
},
|
||||
get errorMessage() {
|
||||
return errorMessage;
|
||||
},
|
||||
set errorMessage(v) {
|
||||
errorMessage = v;
|
||||
}
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('22222222');
|
||||
await expect.element(input).toHaveValue('22.22.2222');
|
||||
expect(value).toBe('');
|
||||
expect(errorMessage).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Typing partial date ──────────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – typing a partial date', () => {
|
||||
it('sets errorMessage and clears value when date is incomplete', async () => {
|
||||
let value = '';
|
||||
let errorMessage: string | null = null;
|
||||
render(DateInput, {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v) {
|
||||
value = v;
|
||||
},
|
||||
get errorMessage() {
|
||||
return errorMessage;
|
||||
},
|
||||
set errorMessage(v) {
|
||||
errorMessage = v;
|
||||
}
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('2212');
|
||||
await expect.element(input).toHaveValue('22.12');
|
||||
expect(value).toBe('');
|
||||
expect(errorMessage).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Clearing date ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – clearing the date', () => {
|
||||
it('resets value and errorMessage to null when cleared', async () => {
|
||||
let value = '';
|
||||
let errorMessage: string | null = null;
|
||||
render(DateInput, {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v) {
|
||||
value = v;
|
||||
},
|
||||
get errorMessage() {
|
||||
return errorMessage;
|
||||
},
|
||||
set errorMessage(v) {
|
||||
errorMessage = v;
|
||||
}
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
// Type a valid date first
|
||||
await input.fill('20122024');
|
||||
expect(value).toBe('2024-12-20');
|
||||
// Now clear
|
||||
await input.fill('');
|
||||
expect(value).toBe('');
|
||||
expect(errorMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hidden input ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – hidden input for form submission', () => {
|
||||
it('renders a hidden input with the given name when name prop is set', async () => {
|
||||
render(DateInput, { name: 'documentDate' });
|
||||
const hidden = document.querySelector('input[type="hidden"][name="documentDate"]');
|
||||
expect(hidden).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not render a hidden input when name prop is absent', async () => {
|
||||
render(DateInput, {});
|
||||
const hidden = document.querySelector('input[type="hidden"]');
|
||||
expect(hidden).toBeNull();
|
||||
});
|
||||
|
||||
it('hidden input value reflects the ISO value', async () => {
|
||||
render(DateInput, { name: 'documentDate', value: '' });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('20122024');
|
||||
await domFlush();
|
||||
const hidden = document.querySelector<HTMLInputElement>(
|
||||
'input[type="hidden"][name="documentDate"]'
|
||||
);
|
||||
expect(hidden?.value).toBe('2024-12-20');
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,11 @@ interface Props {
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
suggestedName?: string;
|
||||
placeholder?: string;
|
||||
compact?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -20,12 +23,23 @@ let {
|
||||
value = $bindable(''),
|
||||
initialName = '',
|
||||
suggestedName = '',
|
||||
placeholder,
|
||||
compact = false,
|
||||
restrictToCorrespondentsOf,
|
||||
onchange
|
||||
onchange,
|
||||
onfocused
|
||||
}: Props = $props();
|
||||
|
||||
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
|
||||
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived
|
||||
let searchTerm = $state(initialName);
|
||||
|
||||
// Sync display text when the selected person changes externally (e.g. swap, navigation).
|
||||
$effect(() => {
|
||||
searchTerm = initialName;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedName;
|
||||
if (suggested && !untrack(() => value)) {
|
||||
@@ -79,6 +93,7 @@ function handleInput() {
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
onfocused?.();
|
||||
showDropdown = true;
|
||||
if (restrictToCorrespondentsOf) {
|
||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||
@@ -120,7 +135,13 @@ function clickOutside(node: HTMLElement) {
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<label for={name} class="block text-sm font-medium text-ink-2">{label}</label>
|
||||
<label
|
||||
for={name}
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: 'block text-sm font-medium text-ink-2'}
|
||||
>{label}</label
|
||||
>
|
||||
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
@@ -131,8 +152,10 @@ function clickOutside(node: HTMLElement) {
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
placeholder={m.comp_typeahead_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border border-line p-2 shadow-sm focus:border-accent focus:ring-accent"
|
||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||
class={compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:border-accent focus:ring-accent'}
|
||||
/>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
|
||||
109
frontend/src/lib/utils/date.spec.ts
Normal file
109
frontend/src/lib/utils/date.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatGermanDateInput, isoToGerman, germanToIso } from './date';
|
||||
|
||||
// ─── isoToGerman ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isoToGerman', () => {
|
||||
it('converts a valid ISO date to DD.MM.YYYY', () => {
|
||||
expect(isoToGerman('2024-12-20')).toBe('20.12.2024');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(isoToGerman('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for invalid format', () => {
|
||||
expect(isoToGerman('not-a-date')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── germanToIso ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('germanToIso', () => {
|
||||
it('converts DD.MM.YYYY to ISO', () => {
|
||||
expect(germanToIso('20.12.2024')).toBe('2024-12-20');
|
||||
});
|
||||
|
||||
it('returns empty string for partial input', () => {
|
||||
expect(germanToIso('20.12')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(germanToIso('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatGermanDateInput ────────────────────────────────────────────────────
|
||||
|
||||
describe('formatGermanDateInput – digit stream (no dots typed)', () => {
|
||||
it('leaves 1–2 digits as-is', () => {
|
||||
expect(formatGermanDateInput('2')).toBe('2');
|
||||
expect(formatGermanDateInput('20')).toBe('20');
|
||||
});
|
||||
|
||||
it('auto-inserts dot after 2 digits for 3–4 digit input', () => {
|
||||
expect(formatGermanDateInput('201')).toBe('20.1');
|
||||
expect(formatGermanDateInput('2012')).toBe('20.12');
|
||||
});
|
||||
|
||||
it('auto-inserts two dots for 5–8 digit input', () => {
|
||||
expect(formatGermanDateInput('20121')).toBe('20.12.1');
|
||||
expect(formatGermanDateInput('20122024')).toBe('20.12.2024');
|
||||
});
|
||||
|
||||
it('ignores digits beyond 8', () => {
|
||||
expect(formatGermanDateInput('201220249')).toBe('20.12.2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGermanDateInput – manual dot entry with padding', () => {
|
||||
it('pads single-digit day to 2 digits when dot is typed after it', () => {
|
||||
expect(formatGermanDateInput('3.')).toBe('03.');
|
||||
});
|
||||
|
||||
it('does not pad a 2-digit day', () => {
|
||||
expect(formatGermanDateInput('03.')).toBe('03.');
|
||||
expect(formatGermanDateInput('20.')).toBe('20.');
|
||||
});
|
||||
|
||||
it('pads single-digit month to 2 digits when dot is typed after it', () => {
|
||||
expect(formatGermanDateInput('03.3.')).toBe('03.03.');
|
||||
});
|
||||
|
||||
it('does not pad a 2-digit month', () => {
|
||||
expect(formatGermanDateInput('03.12.')).toBe('03.12.');
|
||||
});
|
||||
|
||||
it('pads both day and month in a fully typed date', () => {
|
||||
expect(formatGermanDateInput('3.3.2012')).toBe('03.03.2012');
|
||||
});
|
||||
|
||||
it('pads only day when month is already 2 digits', () => {
|
||||
expect(formatGermanDateInput('3.12.2024')).toBe('03.12.2024');
|
||||
});
|
||||
|
||||
it('pads only month when day is already 2 digits', () => {
|
||||
expect(formatGermanDateInput('20.3.2024')).toBe('20.03.2024');
|
||||
});
|
||||
|
||||
it('handles a complete date entered with manual dots and no padding needed', () => {
|
||||
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
|
||||
});
|
||||
|
||||
it('overflows excess day digits into month when dot follows', () => {
|
||||
expect(formatGermanDateInput('123.')).toBe('12.3');
|
||||
});
|
||||
|
||||
it('caps year digits at 4', () => {
|
||||
expect(formatGermanDateInput('03.03.20249')).toBe('03.03.2024');
|
||||
});
|
||||
|
||||
it('overflows excess month digits into year (digit stream then continue typing)', () => {
|
||||
// User typed digits → auto-dot gave "20.12", then types "2" → raw becomes "20.122"
|
||||
expect(formatGermanDateInput('20.122')).toBe('20.12.2');
|
||||
});
|
||||
|
||||
it('continues building year after overflow', () => {
|
||||
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
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'][] = [];
|
||||
@@ -56,6 +61,7 @@ export async function load({ url, fetch }) {
|
||||
|
||||
return {
|
||||
documents,
|
||||
canWrite,
|
||||
initialValues: { senderName, receiverName },
|
||||
filters: { senderId, receiverId, from, to, dir }
|
||||
};
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import ConversationFilterBar from './ConversationFilterBar.svelte';
|
||||
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();
|
||||
|
||||
@@ -14,15 +17,42 @@ let fromDate = $state(untrack(() => data.filters.from));
|
||||
let toDate = $state(untrack(() => data.filters.to));
|
||||
let sortDir = $state(untrack(() => data.filters.dir));
|
||||
|
||||
// Sync with server data after navigation
|
||||
// Derived name states — kept as reactive copies so ConversationTimeline always has current names
|
||||
let senderName = $state(untrack(() => data.initialValues.senderName));
|
||||
let receiverName = $state(untrack(() => data.initialValues.receiverName));
|
||||
|
||||
// Sync with server data after navigation; persist recent persons once the name is resolved
|
||||
$effect(() => {
|
||||
senderId = data.filters.senderId;
|
||||
receiverId = data.filters.receiverId;
|
||||
fromDate = data.filters.from;
|
||||
toDate = data.filters.to;
|
||||
sortDir = data.filters.dir;
|
||||
senderName = data.initialValues.senderName;
|
||||
receiverName = data.initialValues.receiverName;
|
||||
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);
|
||||
@@ -44,51 +74,59 @@ function swapPersons() {
|
||||
receiverId = tmp;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function selectPerson(id: string) {
|
||||
if (!id) {
|
||||
document.querySelector<HTMLInputElement>('#senderId-search')?.focus();
|
||||
return;
|
||||
}
|
||||
senderId = id;
|
||||
receiverId = '';
|
||||
applyFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 border-b border-ink/10 pb-4">
|
||||
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
|
||||
<p class="mt-2 font-sans text-sm text-ink-2">
|
||||
{m.conv_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ConversationFilterBar
|
||||
<!-- 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}
|
||||
bind:fromDate={fromDate}
|
||||
bind:toDate={toDate}
|
||||
bind:sortDir={sortDir}
|
||||
initialSenderName={data.initialValues.senderName}
|
||||
initialReceiverName={data.initialValues.receiverName}
|
||||
onapplyFilters={applyFilters}
|
||||
ontoggleSort={toggleSort}
|
||||
onswapPersons={swapPersons}
|
||||
/>
|
||||
|
||||
<!-- RESULTS LIST SECTION -->
|
||||
{#if !senderId || !receiverId}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
|
||||
>
|
||||
<div class="mb-4 rounded-full bg-muted p-4 text-ink">
|
||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
|
||||
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
|
||||
</div>
|
||||
<!-- 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-surface py-24 text-center shadow-sm"
|
||||
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>
|
||||
@@ -99,6 +137,8 @@ function swapPersons() {
|
||||
senderId={senderId}
|
||||
receiverId={receiverId}
|
||||
canWrite={data.canWrite}
|
||||
senderName={senderName}
|
||||
receiverName={receiverName}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,9 @@ const outPct = $derived(documents.length > 0 ? (outCount / documents.length) * 1
|
||||
|
||||
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-yellow-400',
|
||||
@@ -77,30 +80,30 @@ const newDocUrl = $derived(
|
||||
|
||||
{#if isBilateral && documents.length > 0}
|
||||
<div
|
||||
class="flex flex-col gap-1 border-b border-[#E8E4DF] bg-[#F7F5F2] px-[18px] py-2"
|
||||
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-[10px] font-bold">
|
||||
<span class="text-[#002850]">{outCount} von {senderName} →</span>
|
||||
<span class="text-[#0F5755]">{inCount} von {receiverName} ←</span>
|
||||
<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-[#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 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-[#E0DDD6] bg-white">
|
||||
<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-2 border-t-2 border-b border-[#C8C4BE] border-[#D8D4CE] bg-[#F0EDE6] px-[14px] py-[6px]"
|
||||
class="flex items-baseline gap-3 border-t-2 border-b border-line bg-muted px-[14px] py-[8px]"
|
||||
>
|
||||
<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>
|
||||
<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}
|
||||
|
||||
@@ -109,31 +112,31 @@ const newDocUrl = $derived(
|
||||
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}
|
||||
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-[14px] shrink-0 pt-[1px] text-xs font-black"
|
||||
class:text-[#002850]={isOut}
|
||||
class:text-[#0F5755]={!isOut}
|
||||
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-[#0D2240]">
|
||||
<div class="mb-[2px] truncate text-sm font-bold text-ink">
|
||||
{doc.title || doc.originalFilename}
|
||||
</div>
|
||||
<div class="flex items-center gap-[5px] text-xs text-[#888]">
|
||||
<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-[#D1CCC8]">·</span>
|
||||
<span class="text-line">·</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
{#if !receiverId}
|
||||
<span class="text-[#D1CCC8]">·</span>
|
||||
<span class="text-line">·</span>
|
||||
<span>{otherPartyName(doc)}</span>
|
||||
{/if}
|
||||
<span
|
||||
@@ -144,18 +147,18 @@ const newDocUrl = $derived(
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="shrink-0 text-[#888] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
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-[#E8E4DF] px-[14px] py-[6px]">
|
||||
<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-[#002850]/50 transition-colors hover:text-[#002850]"
|
||||
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"
|
||||
|
||||
@@ -79,11 +79,11 @@ function getInitials(person: Correspondent): string {
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
aria-label={conv_suggestions_heading()}
|
||||
class="absolute top-full right-0 left-0 z-30 mt-1 rounded-sm border border-[#E0DDD6] bg-white shadow-lg"
|
||||
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-[#888] uppercase">
|
||||
<div class="px-3 pt-2 pb-1 text-[10px] font-bold tracking-widest text-ink-3 uppercase">
|
||||
{conv_suggestions_heading()}
|
||||
</div>
|
||||
|
||||
@@ -94,33 +94,32 @@ function getInitials(person: Correspondent): string {
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
tabindex="0"
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-[#333] hover:bg-[#F7F5F2] focus:bg-[#F7F5F2] focus:outline-none"
|
||||
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"
|
||||
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-[#002850] text-[10px] font-bold text-white"
|
||||
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}
|
||||
<!-- TODO: show proportional letter-count bar when counts are available from the API -->
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="mt-1 border-t border-[#E0DDD6]"></div>
|
||||
<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-[#333] hover:bg-[#F7F5F2] focus:bg-[#F7F5F2] focus:outline-none"
|
||||
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"
|
||||
onclick={() => onselect('')}
|
||||
onkeydown={(e) => e.key === 'Enter' && onselect('')}
|
||||
>
|
||||
|
||||
@@ -4,8 +4,7 @@ import { conv_empty_search_placeholder, conv_empty_recent_label } from '$lib/mes
|
||||
|
||||
interface RecentPerson {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -29,20 +28,20 @@ onMount(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex max-w-sm flex-col items-center gap-4 py-16 text-center">
|
||||
<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-[#F0EDE6] p-4">
|
||||
<div class="rounded-full bg-muted p-5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
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-[#002850]"
|
||||
class="text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
@@ -51,10 +50,10 @@ onMount(() => {
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h2 class="font-serif text-sm font-black text-[#0D2240]">Korrespondenz durchsuchen</h2>
|
||||
<h2 class="font-serif text-xl font-black text-ink">Korrespondenz durchsuchen</h2>
|
||||
|
||||
<!-- Subtext -->
|
||||
<p class="max-w-[280px] text-xs text-[#888]">
|
||||
<p class="max-w-sm text-base text-ink-3">
|
||||
Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.
|
||||
</p>
|
||||
|
||||
@@ -64,19 +63,19 @@ onMount(() => {
|
||||
data-testid="conv-empty-search"
|
||||
aria-label={conv_empty_search_placeholder()}
|
||||
onclick={() => onSelectPerson('')}
|
||||
class="flex h-[28px] w-[260px] items-center rounded-sm border border-[#D1D5DB] bg-[#F9F8F6] px-3 text-xs text-[#AAA] italic transition-colors hover:border-[#002850]"
|
||||
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="12"
|
||||
height="12"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mr-1.5 shrink-0"
|
||||
class="mr-2 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
@@ -85,17 +84,17 @@ onMount(() => {
|
||||
Person suchen…
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="flex-1 border-t border-[#E0DDD6]"></div>
|
||||
<span class="text-[10px] font-bold tracking-wider text-[#AAA] uppercase">oder</span>
|
||||
<div class="flex-1 border-t border-[#E0DDD6]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Recent persons -->
|
||||
<!-- Recent persons — only shown when localStorage has entries -->
|
||||
{#if recentPersons.length > 0}
|
||||
<div class="flex w-full flex-col items-center gap-2">
|
||||
<span class="text-[10px] font-bold tracking-widest text-[#888] uppercase">
|
||||
<!-- 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">
|
||||
{conv_empty_recent_label()}
|
||||
</span>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
@@ -104,15 +103,15 @@ onMount(() => {
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSelectPerson(person.id)}
|
||||
class="flex items-center gap-1.5 rounded-full border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-bold text-[#333] transition-colors hover:border-[#002850] hover:text-[#002850]"
|
||||
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-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#002850] text-[10px] text-white"
|
||||
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.firstName[0]}{person.lastName[0]}
|
||||
{person.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span class="hidden sm:inline">{person.lastName}, </span>{person.firstName}
|
||||
{person.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -33,13 +33,13 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-[10px] border-b border-[#E0DDD6] bg-[#F7F5F2] px-4 py-[5px] transition-opacity sm:px-[18px]"
|
||||
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 text-[#888] sm:block">
|
||||
<span class="hidden text-xs font-bold tracking-wide text-ink-3 uppercase sm:block">
|
||||
{conv_strip_period()}
|
||||
</span>
|
||||
|
||||
@@ -50,15 +50,12 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
||||
onchange={() => onapplyFilters()}
|
||||
placeholder={conv_strip_from_placeholder()}
|
||||
aria-label="Von"
|
||||
class="h-[22px] min-h-[44px] w-[80px] rounded-[3px] border px-1 text-xs focus:outline-none sm:min-h-0"
|
||||
class:border-[#002850]={!!fromDate}
|
||||
class:text-[#333]={!!fromDate}
|
||||
class:border-[#D1D5DB]={!fromDate}
|
||||
class:text-[#AAA]={!fromDate}
|
||||
class:italic={!fromDate}
|
||||
class="h-8 min-h-[44px] w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none sm:min-h-0"
|
||||
class:border-primary={!!fromDate}
|
||||
class:border-line={!fromDate}
|
||||
/>
|
||||
|
||||
<span class="text-xs text-[#AAA]">–</span>
|
||||
<span class="text-xs text-ink-3">–</span>
|
||||
|
||||
<!-- To date -->
|
||||
<input
|
||||
@@ -67,20 +64,17 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
||||
onchange={() => onapplyFilters()}
|
||||
placeholder={conv_strip_to_placeholder()}
|
||||
aria-label="Bis"
|
||||
class="h-[22px] min-h-[44px] w-[80px] rounded-[3px] border px-1 text-xs focus:outline-none sm:min-h-0"
|
||||
class:border-[#002850]={!!toDate}
|
||||
class:text-[#333]={!!toDate}
|
||||
class:border-[#D1D5DB]={!toDate}
|
||||
class:text-[#AAA]={!toDate}
|
||||
class:italic={!toDate}
|
||||
class="h-8 min-h-[44px] w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none sm:min-h-0"
|
||||
class:border-primary={!!toDate}
|
||||
class:border-line={!toDate}
|
||||
/>
|
||||
|
||||
<!-- Document count -->
|
||||
<span
|
||||
data-testid="conv-strip-count"
|
||||
class="ml-auto text-xs font-bold"
|
||||
class:text-[#002850]={hasDateFilter}
|
||||
class:text-[#888]={!hasDateFilter}
|
||||
class:text-primary={hasDateFilter}
|
||||
class:text-ink-3={!hasDateFilter}
|
||||
>
|
||||
{conv_letters_count({ count: documentCount ?? 0 })}
|
||||
</span>
|
||||
@@ -92,11 +86,11 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
||||
aria-label="Sortierung umkehren"
|
||||
aria-pressed={sortDir === 'ASC'}
|
||||
onclick={ontoggleSort}
|
||||
class="flex h-[22px] min-h-[44px] items-center gap-1 rounded-[3px] border px-2 text-xs font-bold sm:min-h-0"
|
||||
class:border-[#002850]={isActive}
|
||||
class:text-[#002850]={isActive}
|
||||
class:border-[#D1D5DB]={!isActive}
|
||||
class:text-[#888]={!isActive}
|
||||
class="flex h-8 min-h-[44px] items-center gap-1 rounded border px-3 text-xs font-bold sm:min-h-0"
|
||||
class:border-primary={isActive}
|
||||
class:text-primary={isActive}
|
||||
class:border-line={!isActive}
|
||||
class:text-ink-3={!isActive}
|
||||
>
|
||||
{#if sortDir === 'ASC'}
|
||||
{conv_strip_sort_oldest()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte';
|
||||
|
||||
interface Props {
|
||||
senderId?: string;
|
||||
@@ -20,9 +21,21 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
let swapVisible = $derived(!!(senderId && receiverId));
|
||||
|
||||
let showSuggestions = $state(false);
|
||||
|
||||
function handleCorrespondentFocused() {
|
||||
if (senderId) showSuggestions = true;
|
||||
}
|
||||
|
||||
function handleSuggestionSelect(id: string) {
|
||||
receiverId = id;
|
||||
showSuggestions = false;
|
||||
onapplyFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-end gap-[9px] border-b border-[#EAE7E0] bg-white px-4 py-[9px] sm:px-[18px]">
|
||||
<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
|
||||
@@ -30,6 +43,7 @@ let swapVisible = $derived(!!(senderId && receiverId));
|
||||
label="Person"
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
compact={true}
|
||||
restrictToCorrespondentsOf={receiverId || undefined}
|
||||
onchange={() => onapplyFilters()}
|
||||
/>
|
||||
@@ -41,7 +55,7 @@ let swapVisible = $derived(!!(senderId && receiverId));
|
||||
type="button"
|
||||
aria-label="Personen tauschen"
|
||||
onclick={onswapPersons}
|
||||
class="mb-1 flex h-7 w-7 shrink-0 items-center justify-center rounded border border-[#D1D5DB] bg-white text-[#888] transition-colors hover:border-[#002850] hover:text-[#002850]"
|
||||
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}
|
||||
@@ -65,23 +79,32 @@ let swapVisible = $derived(!!(senderId && receiverId));
|
||||
|
||||
<!-- Korrespondent field -->
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
class="relative min-w-0 flex-1"
|
||||
class:[&_input]:border-dashed={!receiverId}
|
||||
class:[&_input]:border-solid={!!receiverId}
|
||||
class:[&_input]:bg-[#F9F8F6]={!receiverId}
|
||||
class:[&_input]:bg-canvas={!receiverId}
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
label={receiverId ? 'Korrespondent' : 'Korrespondent'}
|
||||
label={receiverId ? 'Korrespondent' : 'Korrespondent — optional'}
|
||||
bind:value={receiverId}
|
||||
initialName={initialReceiverName}
|
||||
compact={true}
|
||||
placeholder="Alle Korrespondenten"
|
||||
restrictToCorrespondentsOf={senderId || undefined}
|
||||
onchange={() => onapplyFilters()}
|
||||
onchange={() => {
|
||||
showSuggestions = false;
|
||||
onapplyFilters();
|
||||
}}
|
||||
onfocused={handleCorrespondentFocused}
|
||||
/>
|
||||
{#if !receiverId}
|
||||
<span class="pointer-events-none absolute -mt-[1px] text-[11px] text-[#AAA] italic">
|
||||
— optional
|
||||
</span>
|
||||
{#if showSuggestions && senderId && !receiverId}
|
||||
<CorrespondentSuggestionsDropdown
|
||||
senderId={senderId}
|
||||
senderName=""
|
||||
onselect={handleSuggestionSelect}
|
||||
onclose={() => (showSuggestions = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
conv_hint_single_person,
|
||||
conv_hint_single_person_filtered,
|
||||
conv_strip_sort_newest,
|
||||
conv_strip_sort_oldest
|
||||
} from '$lib/messages-extra';
|
||||
import { conv_strip_sort_newest, conv_strip_sort_oldest } from '$lib/messages-extra';
|
||||
|
||||
interface Props {
|
||||
senderName: string;
|
||||
@@ -16,8 +11,9 @@ interface Props {
|
||||
let { senderName, fromDate = '', toDate = '', sortDir = 'DESC' }: Props = $props();
|
||||
|
||||
let hasDateFilter = $derived(!!(fromDate || toDate));
|
||||
|
||||
let sortLabel = $derived(sortDir === 'ASC' ? conv_strip_sort_oldest() : conv_strip_sort_newest());
|
||||
let fromYear = $derived(fromDate ? fromDate.substring(0, 4) : '');
|
||||
let toYear = $derived(toDate ? toDate.substring(0, 4) : '');
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -26,13 +22,12 @@ let sortLabel = $derived(sortDir === 'ASC' ? conv_strip_sort_oldest() : conv_str
|
||||
<span class="text-sm" aria-hidden="true">📋</span>
|
||||
|
||||
{#if hasDateFilter}
|
||||
{conv_hint_single_person_filtered({
|
||||
name: senderName,
|
||||
from: fromDate ?? '',
|
||||
to: toDate ?? '',
|
||||
sortLabel
|
||||
})}
|
||||
<strong>{senderName}</strong>
|
||||
<span>·</span>
|
||||
<span>{fromYear}–{toYear}</span>
|
||||
<span>·</span>
|
||||
<span>{sortLabel}</span>
|
||||
{:else}
|
||||
{conv_hint_single_person({ name: senderName })}
|
||||
Alle Briefe von <strong>{senderName}</strong> — wähle einen Korrespondenten oben um einzugrenzen
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,15 @@ const baseData = {
|
||||
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' }
|
||||
};
|
||||
|
||||
@@ -30,6 +37,7 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
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: [],
|
||||
@@ -45,41 +53,120 @@ const withDocs = {
|
||||
documents: [makeDoc()]
|
||||
};
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
// ─── Empty state (no senderId) ────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – empty state', () => {
|
||||
it('shows the "select two persons" prompt when no persons are selected', async () => {
|
||||
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(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the swap button when no persons are selected', async () => {
|
||||
it('shows the empty-search button', async () => {
|
||||
render(Page, { data: baseData });
|
||||
// Button is always in the DOM (holds grid column width on desktop) but made invisible
|
||||
await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
|
||||
await expect.element(page.getByTestId('conv-empty-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the new document link when no persons are selected', async () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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('Conversations page – no results', () => {
|
||||
it('shows "no documents found" when both persons are selected but there are no documents', async () => {
|
||||
render(Page, { data: withPersons });
|
||||
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('Conversations page – swap button', () => {
|
||||
it('shows the swap button when both persons are selected', async () => {
|
||||
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 });
|
||||
await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
|
||||
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 () => {
|
||||
@@ -92,28 +179,9 @@ describe('Conversations page – swap button', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – summary', () => {
|
||||
it('shows document count and year range when documents are loaded', async () => {
|
||||
const data = {
|
||||
...withPersons,
|
||||
documents: [
|
||||
makeDoc({ documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
const summary = page.getByTestId('conv-summary');
|
||||
await expect.element(summary).toHaveTextContent('2');
|
||||
await expect.element(summary).toHaveTextContent('1923');
|
||||
await expect.element(summary).toHaveTextContent('1965');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Year dividers ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – 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');
|
||||
@@ -141,7 +209,6 @@ describe('Conversations page – year dividers', () => {
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
// Only one divider for 1923; 1965 divider should not appear
|
||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||
await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -149,12 +216,21 @@ describe('Conversations page – year dividers', () => {
|
||||
|
||||
// ─── New document link ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – new document link', () => {
|
||||
it('shows the link with correct href for a write user', async () => {
|
||||
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', '/documents/new?senderId=p1&receiverId=p2');
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user