feat(persons): redesign /persons/[id] detail page (Concept A layout)

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-29 19:55:31 +02:00
parent 7141ae1e1f
commit 44e8891ca9
5 changed files with 137 additions and 295 deletions

View File

@@ -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}`);
}
};

View File

@@ -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(() => {
});
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<div class="mx-auto max-w-6xl px-4 py-10">
<!-- Back Link -->
<div class="mb-6">
<a
@@ -64,25 +63,28 @@ const coCorrespondents = $derived.by(() => {
</a>
</div>
<PersonCard person={person} canWrite={data.canWrite} form={form} />
<!-- 2-column layout on large screens -->
<div class="lg:grid lg:grid-cols-[35%_65%] lg:gap-8">
<!-- Left column: Person card -->
<div>
<PersonCard person={person} canWrite={data.canWrite} />
</div>
{#if data.canWrite}
{#key person.id}
<PersonMergePanel person={person} form={form} />
{/key}
{/if}
<!-- Right column: correspondents + documents -->
<div>
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
<PersonDocumentList
documents={sentDocuments}
heading={m.person_docs_heading()}
emptyMessage={m.person_no_docs()}
/>
<PersonDocumentList
documents={sentDocuments}
heading={m.person_docs_heading()}
emptyMessage={m.person_no_docs()}
/>
<PersonDocumentList
documents={receivedDocuments}
heading={m.person_received_docs_heading()}
emptyMessage={m.person_no_received_docs()}
/>
<PersonDocumentList
documents={receivedDocuments}
heading={m.person_received_docs_heading()}
emptyMessage={m.person_no_received_docs()}
/>
</div>
</div>
</div>

View File

@@ -19,8 +19,24 @@ let {
{#each coCorrespondents as c (c.id)}
<a
href="/conversations?senderId={personId}&receiverId={c.id}"
title={m.doc_conversation_title()}
class="inline-flex items-center gap-1.5 rounded-full border border-line px-3 py-1 font-serif text-sm text-ink transition-colors hover:border-primary"
>
<!-- Chat icon -->
<svg
class="h-3.5 w-3.5 flex-shrink-0 text-ink-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{c.name}
<span class="font-sans text-xs text-ink-3">({c.count})</span>
</a>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
let {
person,
canWrite,
form
canWrite
}: {
person: {
id: string;
firstName: string;
lastName: string;
alias?: string | null;
@@ -16,231 +16,84 @@ let {
notes?: string | null;
};
canWrite: boolean;
form?: { updated?: boolean; updateError?: string } | null;
} = $props();
let editMode = $state(false);
$effect(() => {
if (form?.updated) editMode = false;
});
</script>
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<div class="h-2 w-full bg-primary"></div>
<div class="p-8 md:p-10">
{#if editMode && canWrite}
<!-- Edit Form -->
<form method="POST" action="?/update" use:enhance>
<div class="flex flex-col gap-6">
<h2 class="border-b border-line-2 pb-3 font-serif text-xl text-ink">
{m.person_edit_heading()}
</h2>
{#if form?.updateError}
<p class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{form.updateError}
</p>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
for="firstName"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="lastName"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_last_name()} *</label
>
<input
id="lastName"
name="lastName"
type="text"
required
value={person.lastName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label
for="alias"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="birthYear"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="deathYear"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label
for="notes"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</label
>
<textarea
id="notes"
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
>{person.notes ?? ''}</textarea
>
</div>
</div>
<div class="flex gap-3">
<button
type="submit"
class="rounded bg-primary px-5 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>
<button
type="button"
onclick={() => (editMode = false)}
class="rounded border border-line px-5 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
>
{m.btn_cancel()}
</button>
</div>
<!-- View Mode -->
<div class="flex flex-col items-start gap-8 md:flex-row">
<div class="flex-shrink-0">
<div
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
>
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
</div>
</form>
{:else}
<!-- View Mode -->
<div class="flex flex-col items-start gap-8 md:flex-row">
<div class="flex-shrink-0">
<div
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
>
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
</div>
<div class="w-full flex-1">
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
<h1 class="font-serif text-4xl text-ink">
{person.firstName}
{person.lastName}
</h1>
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
{#if canWrite}
<a
href="/persons/{person.id}/edit"
class="inline-flex items-center gap-1.5 rounded border border-line px-3 py-1.5 text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
alt=""
aria-hidden="true"
class="h-3.5 w-3.5"
/>
{m.btn_edit()}
</a>
{/if}
</div>
</div>
<div class="w-full flex-1">
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
<h1 class="font-serif text-4xl text-ink">
{person.firstName}
{person.lastName}
</h1>
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
{#if canWrite}
<button
onclick={() => (editMode = true)}
class="inline-flex items-center gap-1.5 rounded border border-line px-3 py-1.5 text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
alt=""
aria-hidden="true"
class="h-3.5 w-3.5"
/>
{m.btn_edit()}
</button>
{/if}
</div>
</div>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
{#if person.alias}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_full_name()}</span
>
<span class="block font-serif text-lg text-ink"
>{person.firstName} {person.lastName}</span
>{m.form_label_alias()}</span
>
<span class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
</div>
{/if}
{#if person.alias}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</span
>
<span class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
</div>
{/if}
{#if person.birthYear || person.deathYear}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>
{#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}
</span>
<span class="block font-serif text-lg text-ink">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</span>
</div>
{/if}
{#if person.birthYear || person.deathYear}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>
{#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}
</span>
<span class="block font-serif text-lg text-ink">
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
&nbsp;{/if}{#if person.deathYear}{person.deathYear}{/if}
</span>
</div>
{/if}
{#if person.notes}
<div class="md:col-span-2">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</span
>
<p class="font-serif text-base whitespace-pre-wrap text-ink">
{person.notes}
</p>
</div>
{/if}
</div>
{#if person.notes}
<div class="md:col-span-2">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</span
>
<p class="font-serif text-base whitespace-pre-wrap text-ink">
{person.notes}
</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
await expect(load({ params: { id: 'forbidden' }, fetch: mockFetch })).rejects.toMatchObject({
await expect(
load({ params: { id: 'forbidden' }, fetch: mockFetch, locals: mockLocals })
).rejects.toMatchObject({
status: 403
});
});