diff --git a/frontend/messages/de.json b/frontend/messages/de.json index de8e73e6..5017ad9a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -16,7 +16,7 @@ "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", "nav_persons": "Personen", - "nav_conversations": "Konversationen", + "nav_conversations": "Korrespondenz", "nav_admin": "Admin", "nav_logout": "Abmelden", "btn_save": "Speichern", @@ -122,22 +122,39 @@ "person_co_correspondents_heading": "Häufige Korrespondenten", "person_correspondents_hint": "klicken für Konversation", "person_show_more": "+ {count} weitere anzeigen", - "conv_heading": "Konversationen", - "conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.", + "conv_heading": "Korrespondenz", + "conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.", "conv_label_person_a": "Person A (Absender)", - "conv_label_person_b": "Person B (Empfänger)", + "conv_label_person_b": "Korrespondent", "conv_label_from": "Zeitraum von", "conv_label_to": "Zeitraum bis", "conv_sort_label": "Sortierung:", "conv_sort_newest": "Neueste zuerst", "conv_sort_oldest": "Älteste zuerst", - "conv_empty_heading": "Wählen Sie zwei Personen aus", - "conv_empty_text": "Die Korrespondenz wird hier angezeigt.", + "conv_empty_heading": "Korrespondenz durchsuchen", + "conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.", "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", + "conv_label_correspondent_optional": "Korrespondent", + "conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen", + "conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}–{to} · {sortLabel}", + "conv_strip_period": "Zeitraum", + "conv_strip_from_placeholder": "Von…", + "conv_strip_to_placeholder": "Bis…", + "conv_strip_all_correspondents": "Alle Korrespondenten", + "conv_strip_sort_newest": "Neueste", + "conv_strip_sort_oldest": "Älteste", + "conv_suggestions_heading": "Häufigste Korrespondenten", + "conv_suggestions_all_label": "Alle Korrespondenten von {name}", + "conv_letters_count": "{count} Briefe", + "conv_empty_search_placeholder": "Person suchen…", + "conv_empty_recent_label": "Zuletzt geöffnet", + "conv_asym_sent": "{count} von {name} →", + "conv_asym_received": "{count} von {name} ←", + "conv_no_party": "—", "admin_heading": "Admin Dashboard", "admin_tab_users": "Benutzer", "admin_tab_groups": "Gruppen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cc25945f..6b3e157c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -16,7 +16,7 @@ "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", "nav_persons": "Persons", - "nav_conversations": "Conversations", + "nav_conversations": "Correspondence", "nav_admin": "Admin", "nav_logout": "Sign out", "btn_save": "Save", @@ -122,22 +122,39 @@ "person_co_correspondents_heading": "Frequent correspondents", "person_correspondents_hint": "click to view conversation", "person_show_more": "+ {count} more", - "conv_heading": "Conversations", - "conv_subtitle": "Follow the correspondence between two persons chronologically.", + "conv_heading": "Correspondence", + "conv_subtitle": "Browse a person's letters — with or without a correspondent.", "conv_label_person_a": "Person A (Sender)", - "conv_label_person_b": "Person B (Recipient)", + "conv_label_person_b": "Correspondent", "conv_label_from": "Period from", "conv_label_to": "Period to", "conv_sort_label": "Sort:", "conv_sort_newest": "Newest first", "conv_sort_oldest": "Oldest first", - "conv_empty_heading": "Select two persons", - "conv_empty_text": "The correspondence will be shown here.", + "conv_empty_heading": "Browse correspondence", + "conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.", "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", + "conv_label_correspondent_optional": "Correspondent", + "conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down", + "conv_hint_single_person_filtered": "All letters from {name} · {from}–{to} · {sortLabel}", + "conv_strip_period": "Period", + "conv_strip_from_placeholder": "From…", + "conv_strip_to_placeholder": "To…", + "conv_strip_all_correspondents": "All correspondents", + "conv_strip_sort_newest": "Newest", + "conv_strip_sort_oldest": "Oldest", + "conv_suggestions_heading": "Top correspondents", + "conv_suggestions_all_label": "All correspondents of {name}", + "conv_letters_count": "{count} letters", + "conv_empty_search_placeholder": "Search person…", + "conv_empty_recent_label": "Recently opened", + "conv_asym_sent": "{count} from {name} →", + "conv_asym_received": "{count} from {name} ←", + "conv_no_party": "—", "admin_heading": "Admin Dashboard", "admin_tab_users": "Users", "admin_tab_groups": "Groups", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3e2cab2b..0b7a9e36 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -16,7 +16,7 @@ "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", "nav_persons": "Personas", - "nav_conversations": "Conversaciones", + "nav_conversations": "Correspondencia", "nav_admin": "Admin", "nav_logout": "Cerrar sesión", "btn_save": "Guardar", @@ -122,22 +122,39 @@ "person_co_correspondents_heading": "Corresponsales frecuentes", "person_correspondents_hint": "clic para ver conversación", "person_show_more": "+ {count} más", - "conv_heading": "Conversaciones", - "conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.", + "conv_heading": "Correspondencia", + "conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.", "conv_label_person_a": "Persona A (Remitente)", - "conv_label_person_b": "Persona B (Destinatario)", + "conv_label_person_b": "Corresponsal", "conv_label_from": "Período desde", "conv_label_to": "Período hasta", "conv_sort_label": "Ordenar:", "conv_sort_newest": "Más reciente primero", "conv_sort_oldest": "Más antiguo primero", - "conv_empty_heading": "Seleccione dos personas", - "conv_empty_text": "La correspondencia se mostrará aquí.", + "conv_empty_heading": "Explorar correspondencia", + "conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.", "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", + "conv_label_correspondent_optional": "Corresponsal", + "conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar", + "conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}–{to} · {sortLabel}", + "conv_strip_period": "Período", + "conv_strip_from_placeholder": "Desde…", + "conv_strip_to_placeholder": "Hasta…", + "conv_strip_all_correspondents": "Todos los corresponsales", + "conv_strip_sort_newest": "Más reciente", + "conv_strip_sort_oldest": "Más antiguo", + "conv_suggestions_heading": "Corresponsales frecuentes", + "conv_suggestions_all_label": "Todos los corresponsales de {name}", + "conv_letters_count": "{count} cartas", + "conv_empty_search_placeholder": "Buscar persona…", + "conv_empty_recent_label": "Recientemente abiertos", + "conv_asym_sent": "{count} de {name} →", + "conv_asym_received": "{count} de {name} ←", + "conv_no_party": "—", "admin_heading": "Panel de administración", "admin_tab_users": "Usuarios", "admin_tab_groups": "Grupos", diff --git a/frontend/src/lib/components/PanelMetadata.svelte b/frontend/src/lib/components/PanelMetadata.svelte index b819df8d..f8c2719c 100644 --- a/frontend/src/lib/components/PanelMetadata.svelte +++ b/frontend/src/lib/components/PanelMetadata.svelte @@ -175,7 +175,7 @@ let { doc }: { doc: Doc } = $props(); {#if doc.sender} diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index b7be22ff..d376129c 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -724,6 +724,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/reset-token-for-test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getResetTokenForTest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/import-status": { parameters: { query?: never; @@ -1030,8 +1046,6 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -1040,14 +1054,16 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; + first?: boolean; + last?: boolean; empty?: boolean; }; PageableObject: { - paged?: boolean; /** Format: int32 */ pageNumber?: number; /** Format: int32 */ pageSize?: number; + paged?: boolean; /** Format: int64 */ offset?: number; sort?: components["schemas"]["SortObject"]; @@ -2223,7 +2239,9 @@ export interface operations { query?: { page?: number; size?: number; + /** @description Filter by notification type */ type?: "REPLY" | "MENTION"; + /** @description Filter by read status */ read?: boolean; }; header?: never; @@ -2474,7 +2492,7 @@ export interface operations { parameters: { query: { senderId: string; - receiverId: string; + receiverId?: string; from?: string; to?: string; dir?: string; @@ -2496,6 +2514,28 @@ export interface operations { }; }; }; + getResetTokenForTest: { + parameters: { + query: { + email: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; importStatus: { parameters: { query?: never; diff --git a/frontend/src/routes/AppNav.svelte b/frontend/src/routes/AppNav.svelte index aea54a29..8e17dbed 100644 --- a/frontend/src/routes/AppNav.svelte +++ b/frontend/src/routes/AppNav.svelte @@ -60,9 +60,9 @@ function handleOverlayKeydown(event: KeyboardEvent) { @@ -161,9 +161,9 @@ function handleOverlayKeydown(event: KeyboardEvent) { diff --git a/frontend/src/routes/korrespondenz/+page.server.ts b/frontend/src/routes/korrespondenz/+page.server.ts new file mode 100644 index 00000000..62786d44 --- /dev/null +++ b/frontend/src/routes/korrespondenz/+page.server.ts @@ -0,0 +1,64 @@ +import type { components } from '$lib/generated/api'; +import { createApiClient } from '$lib/api.server'; + +export async function load({ url, fetch }) { + 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 api = createApiClient(fetch); + + let documents: components['schemas']['Document'][] = []; + let senderName = ''; + let receiverName = ''; + + const requests: Promise[] = []; + + if (senderId && receiverId) { + requests.push( + api + .GET('/api/documents/conversation', { + params: { + query: { + senderId, + receiverId, + dir, + from: from || undefined, + to: to || undefined + } + } + }) + .then(({ data }) => { + documents = data ?? []; + }) + ); + } + + if (senderId) { + requests.push( + api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => { + const p = 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(({ data }) => { + const p = data as { firstName: string; lastName: string } | undefined; + if (p) receiverName = `${p.firstName} ${p.lastName}`; + }) + ); + } + + await Promise.all(requests); + + return { + documents, + initialValues: { senderName, receiverName }, + filters: { senderId, receiverId, from, to, dir } + }; +} diff --git a/frontend/src/routes/korrespondenz/+page.svelte b/frontend/src/routes/korrespondenz/+page.svelte new file mode 100644 index 00000000..f151d4b2 --- /dev/null +++ b/frontend/src/routes/korrespondenz/+page.svelte @@ -0,0 +1,104 @@ + + +
+ +
+

{m.conv_heading()}

+

+ {m.conv_subtitle()} +

+
+ + + + + {#if !senderId || !receiverId} +
+
+ +
+

{m.conv_empty_heading()}

+

{m.conv_empty_text()}

+
+ {:else if data.documents.length === 0} +
+

{m.conv_no_results_heading()}

+

{m.conv_no_results_text()}

+
+ {:else} + + {/if} +
diff --git a/frontend/src/routes/korrespondenz/ConversationFilterBar.svelte b/frontend/src/routes/korrespondenz/ConversationFilterBar.svelte new file mode 100644 index 00000000..25e00e5d --- /dev/null +++ b/frontend/src/routes/korrespondenz/ConversationFilterBar.svelte @@ -0,0 +1,142 @@ + + +
+
+ +
+ onapplyFilters()} + /> +
+ + +
+ +
+ + +
+ onapplyFilters()} + /> +
+
+ +
+ +
+ + onapplyFilters()} + class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink" + /> +
+ + +
+ + onapplyFilters()} + class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink" + /> +
+ + +
+ +
+
+
diff --git a/frontend/src/routes/korrespondenz/ConversationTimeline.svelte b/frontend/src/routes/korrespondenz/ConversationTimeline.svelte new file mode 100644 index 00000000..6fc8bc56 --- /dev/null +++ b/frontend/src/routes/korrespondenz/ConversationTimeline.svelte @@ -0,0 +1,164 @@ + + + +
+ {#if yearFrom !== null && yearTo !== null} +

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

+ {:else} +

+ {documents.length} +

+ {/if} + {#if canWrite} +
+ + + + {m.conv_new_doc_link()} + + {/if} +
+ + +
+ + + +
+
+ {#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)} + {#if showYearDivider} +
+
+ {year} +
+
+ {/if} + {@const isRight = doc.sender?.id === senderId} + + + + {/each} +
+
+
diff --git a/frontend/src/routes/korrespondenz/page.svelte.spec.ts b/frontend/src/routes/korrespondenz/page.svelte.spec.ts new file mode 100644 index 00000000..85f18e63 --- /dev/null +++ b/frontend/src/routes/korrespondenz/page.svelte.spec.ts @@ -0,0 +1,164 @@ +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 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: undefined, + filePath: undefined, + 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 }); + document.querySelector('[data-testid="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/persons/[id]/CoCorrespondentsList.svelte b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte index fc0cc90b..b6f0b01d 100644 --- a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte +++ b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte @@ -30,7 +30,7 @@ function initials(name: string): string {
{#each coCorrespondents as c (c.id)}