From 44e8891ca9cdf8f2a8673baafbbe1d5cb17c8d46 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:55:31 +0200 Subject: [PATCH] feat(persons): redesign /persons/[id] detail page (Concept A layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersonCard: remove edit toggle, add Edit→/edit link; 2-column layout on lg; CoCorrespondentsList: add chat icon + title tooltip; remove update/merge actions. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/persons/[id]/+page.server.ts | 70 +---- frontend/src/routes/persons/[id]/+page.svelte | 44 +-- .../persons/[id]/CoCorrespondentsList.svelte | 16 ++ .../src/routes/persons/[id]/PersonCard.svelte | 271 ++++-------------- .../routes/persons/[id]/page.server.spec.ts | 31 +- 5 files changed, 137 insertions(+), 295 deletions(-) diff --git a/frontend/src/routes/persons/[id]/+page.server.ts b/frontend/src/routes/persons/[id]/+page.server.ts index 0bb0ca2a..6b7e8f79 100644 --- a/frontend/src/routes/persons/[id]/+page.server.ts +++ b/frontend/src/routes/persons/[id]/+page.server.ts @@ -1,11 +1,16 @@ -import { error, fail, redirect } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; -export async function load({ params, fetch }) { +export async function load({ params, fetch, locals }) { const { id } = params; const api = createApiClient(fetch); + const canWrite = + (locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) => + g.permissions.includes('WRITE_ALL') + ) ?? false; + const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([ api.GET('/api/persons/{id}', { params: { path: { id } } }), api.GET('/api/persons/{id}/documents', { params: { path: { id } } }), @@ -20,64 +25,7 @@ export async function load({ params, fetch }) { return { person: personResult.data!, sentDocuments: sentDocsResult.data ?? [], - receivedDocuments: receivedDocsResult.data ?? [] + receivedDocuments: receivedDocsResult.data ?? [], + canWrite }; } - -export const actions = { - update: async ({ request, params, fetch }) => { - const formData = await request.formData(); - const firstName = formData.get('firstName')?.toString().trim(); - const lastName = formData.get('lastName')?.toString().trim(); - const alias = formData.get('alias')?.toString().trim() || undefined; - const notes = formData.get('notes')?.toString().trim() || undefined; - const birthYearStr = formData.get('birthYear')?.toString().trim(); - const deathYearStr = formData.get('deathYear')?.toString().trim(); - const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined; - const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined; - - if (!firstName || !lastName) { - return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' }); - } - - const api = createApiClient(fetch); - const { error: apiError } = await api.PUT('/api/persons/{id}', { - params: { path: { id: params.id } }, - body: { - firstName, - lastName, - ...(alias ? { alias } : {}), - ...(notes ? { notes } : {}), - ...(birthYear ? { birthYear } : {}), - ...(deathYear ? { deathYear } : {}) - } - }); - - if (apiError) { - return fail(400, { updateError: 'Speichern fehlgeschlagen.' }); - } - - return { updated: true }; - }, - - merge: async ({ request, params, fetch }) => { - const formData = await request.formData(); - const targetPersonId = formData.get('targetPersonId')?.toString(); - - if (!targetPersonId) { - return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' }); - } - - const api = createApiClient(fetch); - const { error: apiError } = await api.POST('/api/persons/{id}/merge', { - params: { path: { id: params.id } }, - body: { targetPersonId } - }); - - if (apiError) { - return fail(400, { mergeError: 'Zusammenführen fehlgeschlagen.' }); - } - - throw redirect(303, `/persons/${targetPersonId}`); - } -}; diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index a37e87da..2da3e2f2 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -2,11 +2,10 @@ import { m } from '$lib/paraglide/messages.js'; import { SvelteMap } from 'svelte/reactivity'; import PersonCard from './PersonCard.svelte'; -import PersonMergePanel from './PersonMergePanel.svelte'; import CoCorrespondentsList from './CoCorrespondentsList.svelte'; import PersonDocumentList from './PersonDocumentList.svelte'; -let { data, form } = $props(); +let { data } = $props(); const person = $derived(data.person); const sentDocuments = $derived(data.sentDocuments); @@ -47,7 +46,7 @@ const coCorrespondents = $derived.by(() => { }); -
+
- + +
+ +
+ +
- {#if data.canWrite} - {#key person.id} - - {/key} - {/if} + +
+ - + - - - + +
+
diff --git a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte index 5b0409eb..81855ed2 100644 --- a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte +++ b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte @@ -19,8 +19,24 @@ let { {#each coCorrespondents as c (c.id)} + + {c.name} ({c.count}) diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte b/frontend/src/routes/persons/[id]/PersonCard.svelte index 27844aad..e7f8341e 100644 --- a/frontend/src/routes/persons/[id]/PersonCard.svelte +++ b/frontend/src/routes/persons/[id]/PersonCard.svelte @@ -1,13 +1,13 @@
- {#if editMode && canWrite} - -
-
-

- {m.person_edit_heading()} -

- - {#if form?.updateError} -

- {form.updateError} -

- {/if} - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
+ +
+
+
+ {person.firstName[0]}{person.lastName[0]}
- - {:else} - -
-
-
- {person.firstName[0]}{person.lastName[0]} +
+ +
+
+

+ {person.firstName} + {person.lastName} +

+
+ {#if canWrite} + + + {m.btn_edit()} + + {/if}
-
-
-

- {person.firstName} - {person.lastName} -

-
- {#if canWrite} - - {/if} -
-
- -
+
+ {#if person.alias}
{m.person_label_full_name()} - {person.firstName} {person.lastName}{m.form_label_alias()} + "{person.alias}"
+ {/if} - {#if person.alias} -
- {m.form_label_alias()} - "{person.alias}" -
- {/if} + {#if person.birthYear || person.deathYear} +
+ + {#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if} + + + {formatLifeDateRange(person.birthYear, person.deathYear)} + +
+ {/if} - {#if person.birthYear || person.deathYear} -
- - {#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if} - - - {#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear} -  {/if}{#if person.deathYear}† {person.deathYear}{/if} - -
- {/if} - - {#if person.notes} -
- {m.person_label_notes()} -

- {person.notes} -

-
- {/if} -
+ {#if person.notes} +
+ {m.person_label_notes()} +

+ {person.notes} +

+
+ {/if}
- {/if} +
diff --git a/frontend/src/routes/persons/[id]/page.server.spec.ts b/frontend/src/routes/persons/[id]/page.server.spec.ts index e1e8f493..dcdb1c5f 100644 --- a/frontend/src/routes/persons/[id]/page.server.spec.ts +++ b/frontend/src/routes/persons/[id]/page.server.spec.ts @@ -6,6 +6,8 @@ vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); import { createApiClient } from '$lib/api.server'; const mockFetch = vi.fn() as unknown as typeof fetch; +const mockLocals = { user: { groups: [{ permissions: ['READ_ALL'] }] } }; +const mockLocalsWriter = { user: { groups: [{ permissions: ['WRITE_ALL'] }] } }; beforeEach(() => vi.clearAllMocks()); @@ -24,13 +26,30 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - const result = await load({ params: { id: 'p1' }, fetch: mockFetch }); + const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); expect(result.person.firstName).toBe('Hans'); expect(result.sentDocuments).toHaveLength(1); expect(result.receivedDocuments).toEqual([]); }); + it('returns canWrite=true when user has WRITE_ALL', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ + response: { ok: true, status: 200 }, + data: { id: 'p1', firstName: 'Anna', lastName: 'Schmidt' } + }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + } as ReturnType); + + const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter }); + + expect(result.canWrite).toBe(true); + }); + it('returns empty arrays when sent/received document APIs fail', async () => { vi.mocked(createApiClient).mockReturnValue({ GET: vi @@ -43,7 +62,7 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: false }, data: null }) } as ReturnType); - const result = await load({ params: { id: 'p1' }, fetch: mockFetch }); + const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); expect(result.sentDocuments).toEqual([]); expect(result.receivedDocuments).toEqual([]); @@ -62,7 +81,9 @@ describe('person detail load — error paths', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - await expect(load({ params: { id: 'missing' }, fetch: mockFetch })).rejects.toMatchObject({ + await expect( + load({ params: { id: 'missing' }, fetch: mockFetch, locals: mockLocals }) + ).rejects.toMatchObject({ status: 404 }); }); @@ -76,7 +97,9 @@ describe('person detail load — error paths', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - await expect(load({ params: { id: 'forbidden' }, fetch: mockFetch })).rejects.toMatchObject({ + await expect( + load({ params: { id: 'forbidden' }, fetch: mockFetch, locals: mockLocals }) + ).rejects.toMatchObject({ status: 403 }); });