diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index 6db4c8c4..3cab1866 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -12,28 +12,61 @@ let { canWrite, error, total = 0, - q = '' + q = '', + sort = 'DATE' }: { items: DocumentSearchItem[]; canWrite: boolean; error?: string | null; total?: number; q?: string; + sort?: string; } = $props(); -const yearGroups = $derived.by(() => { +const groups = $derived.by(() => { + if (sort === 'SENDER') return groupBySender(items); + if (sort === 'RECEIVER') return groupByReceiver(items); + return groupByYear(items); +}); + +function groupByYear(docItems: DocumentSearchItem[]) { const map = new SvelteMap(); - for (const item of items) { - const year = item.document.documentDate?.substring(0, 4) ?? 'Ohne Datum'; - const group = map.get(year); - if (group) { - group.push(item); - } else { - map.set(year, [item]); + for (const item of docItems) { + const label = item.document.documentDate?.substring(0, 4) ?? 'Ohne Datum'; + const bucket = map.get(label); + if (bucket) bucket.push(item); + else map.set(label, [item]); + } + return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); +} + +function groupBySender(docItems: DocumentSearchItem[]) { + const map = new SvelteMap(); + for (const item of docItems) { + const label = item.document.sender?.displayName ?? m.docs_group_unknown_sender(); + const bucket = map.get(label); + if (bucket) bucket.push(item); + else map.set(label, [item]); + } + return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); +} + +function groupByReceiver(docItems: DocumentSearchItem[]) { + const map = new SvelteMap(); + for (const item of docItems) { + const receivers = item.document.receivers ?? []; + const labels = + receivers.length > 0 + ? receivers.map((r) => r.displayName) + : [m.docs_group_unknown_receiver()]; + for (const label of labels) { + const bucket = map.get(label); + if (bucket) bucket.push(item); + else map.set(label, [item]); } } - return Array.from(map.entries()).map(([year, groupItems]) => ({ year, items: groupItems })); -}); + return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); +} @@ -67,19 +100,21 @@ const yearGroups = $derived.by(() => { {:else if items.length > 0} - - {#each yearGroups as group (group.year)} + + {#each groups as group (group.label)}
- {group.year}{group.label}
    - {#each group.items as item (item.document.id)} + {#each group.items as item (group.label + '-' + item.document.id)} {/each}
diff --git a/frontend/src/routes/DocumentList.svelte.spec.ts b/frontend/src/routes/DocumentList.svelte.spec.ts index f90882fa..e787e176 100644 --- a/frontend/src/routes/DocumentList.svelte.spec.ts +++ b/frontend/src/routes/DocumentList.svelte.spec.ts @@ -18,7 +18,7 @@ function makeItem(overrides: Partial = {}): DocumentSearchIt originalFilename: 'testbrief.pdf', status: 'UPLOADED', documentDate: '2024-03-15', - sender: null, + sender: undefined, receivers: [], tags: [], createdAt: '2024-01-01T00:00:00Z', @@ -105,6 +105,122 @@ describe('DocumentList – year grouping', () => { }); }); +// ─── Sender grouping ───────────────────────────────────────────────────────── + +describe('DocumentList – sender grouping', () => { + it('groups by sender displayName when sort is SENDER', async () => { + const items = [ + makeItem({ + document: { + ...makeItem().document, + id: '1', + sender: { + id: 's1', + lastName: 'Mustermann', + displayName: 'Max Mustermann', + personType: 'PERSON' + } + } + }), + makeItem({ + document: { + ...makeItem().document, + id: '2', + sender: { + id: 's2', + lastName: 'Musterfrau', + displayName: 'Anna Musterfrau', + personType: 'PERSON' + } + } + }) + ]; + render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' }); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Max Mustermann' })) + .toBeInTheDocument(); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Anna Musterfrau' })) + .toBeInTheDocument(); + }); + + it('groups documents with the same sender into one card', async () => { + const sender = { + id: 's1', + lastName: 'Mustermann', + displayName: 'Max Mustermann', + personType: 'PERSON' as const + }; + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', sender } }), + makeItem({ document: { ...makeItem().document, id: '2', sender } }) + ]; + render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' }); + const cards = page.getByTestId('year-card'); + await expect.element(cards.first()).toBeInTheDocument(); + await expect.element(cards.nth(1)).not.toBeInTheDocument(); + }); + + it('places items with no sender under fallback label', async () => { + const items = [makeItem({ document: { ...makeItem().document, id: '1', sender: undefined } })]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' }); + await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument(); + }); +}); + +// ─── Receiver grouping ──────────────────────────────────────────────────────── + +describe('DocumentList – receiver grouping', () => { + it('groups by receiver displayName when sort is RECEIVER', async () => { + const items = [ + makeItem({ + document: { + ...makeItem().document, + id: '1', + receivers: [ + { id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' } + ] + } + }) + ]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Felix Brandt' })) + .toBeInTheDocument(); + }); + + it('duplicates a document into each receiver group', async () => { + const items = [ + makeItem({ + document: { + ...makeItem().document, + id: '1', + title: 'Rundbriefchen', + receivers: [ + { id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' }, + { id: 'r2', lastName: 'Meier', displayName: 'Hans Meier', personType: 'PERSON' } + ] + } + }) + ]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Felix Brandt' })) + .toBeInTheDocument(); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Hans Meier' })) + .toBeInTheDocument(); + const cards = page.getByTestId('year-card'); + await expect.element(cards.nth(1)).toBeInTheDocument(); + }); + + it('places items with no receivers under fallback label', async () => { + const items = [makeItem({ document: { ...makeItem().document, id: '1', receivers: [] } })]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); + await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument(); + }); +}); + // ─── DocumentRow rendering (delegated) ─────────────────────────────────────── describe('DocumentList – DocumentRow delegation', () => { diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 588fc998..57054ff6 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -119,5 +119,6 @@ $effect(() => { q={data.q} canWrite={data.canWrite} error={data.error} + sort={sort} />