diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 1c2d4843..52a3f977 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -140,6 +140,9 @@ "conv_empty_text": "Die Korrespondenz wird hier angezeigt.", "conv_no_results_heading": "Keine Dokumente gefunden.", "conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.", + "conv_swap_btn": "Personen tauschen", + "conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}", + "conv_new_doc_link": "Neues Dokument in dieser Korrespondenz", "admin_heading": "Admin Dashboard", "admin_tab_users": "Benutzer", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1a340e11..75fa54f9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -140,6 +140,9 @@ "conv_empty_text": "The correspondence will be shown here.", "conv_no_results_heading": "No documents found.", "conv_no_results_text": "Try adjusting the time period.", + "conv_swap_btn": "Swap persons", + "conv_summary": "{count} documents · {yearFrom}–{yearTo}", + "conv_new_doc_link": "New document in this correspondence", "admin_heading": "Admin Dashboard", "admin_tab_users": "Users", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2ca24a72..58be11ed 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -140,6 +140,9 @@ "conv_empty_text": "La correspondencia se mostrará aquí.", "conv_no_results_heading": "No se encontraron documentos.", "conv_no_results_text": "Intente ajustar el período de tiempo.", + "conv_swap_btn": "Intercambiar personas", + "conv_summary": "{count} documentos · {yearFrom}–{yearTo}", + "conv_new_doc_link": "Nuevo documento en esta correspondencia", "admin_heading": "Panel de administración", "admin_tab_users": "Usuarios", diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 854b45c4..8467a3b2 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -14,6 +14,14 @@ let fromDate = $state(untrack(() => data.filters.from)); let toDate = $state(untrack(() => data.filters.to)); let sortDir = $state(untrack(() => data.filters.dir)); +const documentYears = $derived( + data.documents + .map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null)) + .filter((y): y is number => y !== null) +); +const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null); +const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null); + // Sync with server data after navigation $effect(() => { senderId = data.filters.senderId; @@ -37,6 +45,24 @@ function toggleSort() { sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC'; applyFilters(); } + +function swapPersons() { + const tmp = senderId; + senderId = receiverId; + receiverId = tmp; + applyFilters(); +} + +const enrichedDocuments = $derived( + data.documents.map((doc, i) => { + const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null; + const prevYear = + i > 0 && data.documents[i - 1].documentDate + ? new Date(data.documents[i - 1].documentDate!).getFullYear() + : null; + return { doc, year, showYearDivider: year !== null && year !== prevYear }; + }) +);
@@ -50,7 +76,7 @@ function toggleSort() {
-
+
+ +
+ +
+
{m.conv_no_results_text()}

{:else} + +
+ {#if yearFrom !== null && yearTo !== null} +

+ {m.conv_summary({ count: data.documents.length, yearFrom, yearTo })} +

+ {:else} +

+ {data.documents.length} +

+ {/if} + {#if data.canWrite} + + + + + {m.conv_new_doc_link()} + + {/if} +
+
@@ -172,7 +254,17 @@ function toggleSort() {
- {#each data.documents as doc (doc.id)} + {#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)} + {#if showYearDivider} +
+
+ {year} +
+
+ {/if} {@const isRight = doc.sender?.id === senderId} diff --git a/frontend/src/routes/conversations/page.svelte.spec.ts b/frontend/src/routes/conversations/page.svelte.spec.ts new file mode 100644 index 00000000..c7cb41fc --- /dev/null +++ b/frontend/src/routes/conversations/page.svelte.spec.ts @@ -0,0 +1,162 @@ +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 = { + canWrite: true, + documents: [], + initialValues: { senderName: '', receiverName: '' }, + filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const } +}; + +const withPersons = { + ...baseData, + filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' } +}; + +const makeDoc = (overrides: Record = {}) => ({ + id: 'd1', + title: 'Testbrief', + originalFilename: 'testbrief.pdf', + status: 'UPLOADED' as const, + documentDate: '1923-04-12', + location: 'Berlin', + sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' }, + receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }], + tags: [], + transcription: null, + filePath: null, + createdAt: '1923-04-12T00:00:00Z', + updatedAt: '1923-04-12T00:00:00Z', + ...overrides +}); + +const withDocs = { + ...withPersons, + documents: [makeDoc()] +}; + +// ─── Empty state ────────────────────────────────────────────────────────────── + +describe('Conversations page – empty state', () => { + it('shows the "select two persons" prompt when no persons are selected', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument(); + }); + + it('hides the swap button when no persons are selected', 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'); + }); + + it('does not show the new document link when no persons are selected', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); + }); +}); + +// ─── 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 }); + 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 () => { + render(Page, { data: withPersons }); + await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible'); + }); + + 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 }); + await page.getByTestId('conv-swap-btn').click(); + expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything()); + expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything()); + }); +}); + +// ─── 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', () => { + 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 }); + // 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(); + }); +}); + +// ─── New document link ──────────────────────────────────────────────────────── + +describe('Conversations page – new document link', () => { + it('shows the link with correct href for a write user', 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'); + }); + + 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(); + }); +}); diff --git a/frontend/src/routes/documents/new/+page.server.ts b/frontend/src/routes/documents/new/+page.server.ts index 4cfe77e7..6ef92f14 100644 --- a/frontend/src/routes/documents/new/+page.server.ts +++ b/frontend/src/routes/documents/new/+page.server.ts @@ -5,10 +5,12 @@ import { parseBackendError, getErrorMessage } from '$lib/errors'; export async function load({ fetch, - locals + locals, + url }: { fetch: typeof globalThis.fetch; locals: App.Locals; + url: URL; }) { const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => @@ -16,14 +18,41 @@ export async function load({ ) ?? false; if (!canWrite) throw error(403, 'Forbidden'); - const api = createApiClient(fetch); - const personsResult = await api.GET('/api/persons'); + const senderId = url.searchParams.get('senderId') || ''; + const receiverId = url.searchParams.get('receiverId') || ''; - if (!personsResult.response.ok) { - return { persons: [] }; + const api = createApiClient(fetch); + + let initialSenderName = ''; + let initialReceivers: { id: string; firstName: string; lastName: string }[] = []; + + const requests: Promise[] = []; + + if (senderId) { + requests.push( + api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => { + if (data) initialSenderName = `${data.firstName} ${data.lastName}`; + }) + ); } - return { persons: personsResult.data }; + if (receiverId) { + requests.push( + api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => { + if (data) + initialReceivers = [{ id: data.id!, firstName: data.firstName, lastName: data.lastName }]; + }) + ); + } + + const [personsResult] = await Promise.all([api.GET('/api/persons'), ...requests]); + + return { + persons: personsResult.response.ok ? personsResult.data : [], + initialSenderId: senderId, + initialSenderName, + initialReceivers + }; } export const actions = { diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte index e1b1d2d4..f6808de6 100644 --- a/frontend/src/routes/documents/new/+page.svelte +++ b/frontend/src/routes/documents/new/+page.svelte @@ -3,13 +3,16 @@ import { enhance } from '$app/forms'; import TagInput from '$lib/components/TagInput.svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte'; +import { untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; -let { form } = $props(); +let { data, form } = $props(); let tags: string[] = $state([]); -let senderId = $state(''); -let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state([]); +let senderId = $state(untrack(() => data.initialSenderId)); +let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state( + untrack(() => data.initialReceivers) +); let dateDisplay = $state(''); let dateIso = $state(''); @@ -120,7 +123,12 @@ function handleDateInput(e: Event) {
- +
diff --git a/frontend/src/routes/documents/new/page.svelte.spec.ts b/frontend/src/routes/documents/new/page.svelte.spec.ts new file mode 100644 index 00000000..7968fe30 --- /dev/null +++ b/frontend/src/routes/documents/new/page.svelte.spec.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +afterEach(cleanup); + +// ─── Test data ──────────────────────────────────────────────────────────────── + +const baseData = { + persons: [], + initialSenderId: '', + initialSenderName: '', + initialReceivers: [] +}; + +// ─── Prefill – sender ───────────────────────────────────────────────────────── + +describe('New document page – sender prefill', () => { + it('shows an empty sender input when no senderId is in the URL', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('#senderId-search'); + expect(input?.value).toBe(''); + }); + + it('shows the sender name in the typeahead input when initialSenderName is set', async () => { + render(Page, { + data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' } + }); + const input = document.querySelector('#senderId-search'); + expect(input?.value).toBe('Hans Müller'); + }); + + it('sets the hidden senderId input to the prefilled ID', async () => { + render(Page, { + data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' } + }); + const hidden = document.querySelector( + 'input[type="hidden"][name="senderId"]' + ); + expect(hidden?.value).toBe('p1'); + }); +}); + +// ─── Prefill – receiver ─────────────────────────────────────────────────────── + +describe('New document page – receiver prefill', () => { + it('shows no receiver chips when initialReceivers is empty', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument(); + }); + + it('shows a receiver chip when initialReceivers has a person', async () => { + const data = { + ...baseData, + initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }] + }; + render(Page, { data }); + await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); + }); + + it('renders a hidden receiverIds input for the prefilled receiver', async () => { + const data = { + ...baseData, + initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }] + }; + render(Page, { data }); + const hidden = document.querySelector('input[name="receiverIds"]'); + expect(hidden?.value).toBe('p2'); + }); +});