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:
@@ -1,11 +1,16 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch, locals }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
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([
|
const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
||||||
@@ -20,64 +25,7 @@ export async function load({ params, fetch }) {
|
|||||||
return {
|
return {
|
||||||
person: personResult.data!,
|
person: personResult.data!,
|
||||||
sentDocuments: sentDocsResult.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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import PersonCard from './PersonCard.svelte';
|
import PersonCard from './PersonCard.svelte';
|
||||||
import PersonMergePanel from './PersonMergePanel.svelte';
|
|
||||||
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||||
import PersonDocumentList from './PersonDocumentList.svelte';
|
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const person = $derived(data.person);
|
const person = $derived(data.person);
|
||||||
const sentDocuments = $derived(data.sentDocuments);
|
const sentDocuments = $derived(data.sentDocuments);
|
||||||
@@ -47,7 +46,7 @@ const coCorrespondents = $derived.by(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</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 -->
|
<!-- Back Link -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a
|
<a
|
||||||
@@ -64,25 +63,28 @@ const coCorrespondents = $derived.by(() => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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}
|
<!-- Right column: correspondents + documents -->
|
||||||
{#key person.id}
|
<div>
|
||||||
<PersonMergePanel person={person} form={form} />
|
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
|
||||||
{/key}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
|
<PersonDocumentList
|
||||||
|
documents={sentDocuments}
|
||||||
|
heading={m.person_docs_heading()}
|
||||||
|
emptyMessage={m.person_no_docs()}
|
||||||
|
/>
|
||||||
|
|
||||||
<PersonDocumentList
|
<PersonDocumentList
|
||||||
documents={sentDocuments}
|
documents={receivedDocuments}
|
||||||
heading={m.person_docs_heading()}
|
heading={m.person_received_docs_heading()}
|
||||||
emptyMessage={m.person_no_docs()}
|
emptyMessage={m.person_no_received_docs()}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<PersonDocumentList
|
</div>
|
||||||
documents={receivedDocuments}
|
|
||||||
heading={m.person_received_docs_heading()}
|
|
||||||
emptyMessage={m.person_no_received_docs()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,8 +19,24 @@ let {
|
|||||||
{#each coCorrespondents as c (c.id)}
|
{#each coCorrespondents as c (c.id)}
|
||||||
<a
|
<a
|
||||||
href="/conversations?senderId={personId}&receiverId={c.id}"
|
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"
|
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}
|
{c.name}
|
||||||
<span class="font-sans text-xs text-ink-3">({c.count})</span>
|
<span class="font-sans text-xs text-ink-3">({c.count})</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
person,
|
person,
|
||||||
canWrite,
|
canWrite
|
||||||
form
|
|
||||||
}: {
|
}: {
|
||||||
person: {
|
person: {
|
||||||
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
@@ -16,231 +16,84 @@ let {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
};
|
};
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
form?: { updated?: boolean; updateError?: string } | null;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let editMode = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (form?.updated) editMode = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
<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="h-2 w-full bg-primary"></div>
|
||||||
|
|
||||||
<div class="p-8 md:p-10">
|
<div class="p-8 md:p-10">
|
||||||
{#if editMode && canWrite}
|
<!-- View Mode -->
|
||||||
<!-- Edit Form -->
|
<div class="flex flex-col items-start gap-8 md:flex-row">
|
||||||
<form method="POST" action="?/update" use:enhance>
|
<div class="flex-shrink-0">
|
||||||
<div class="flex flex-col gap-6">
|
<div
|
||||||
<h2 class="border-b border-line-2 pb-3 font-serif text-xl text-ink">
|
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
|
||||||
{m.person_edit_heading()}
|
>
|
||||||
</h2>
|
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
|
||||||
|
|
||||||
{#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>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
{:else}
|
|
||||||
<!-- View Mode -->
|
<div class="w-full flex-1">
|
||||||
<div class="flex flex-col items-start gap-8 md:flex-row">
|
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
|
||||||
<div class="flex-shrink-0">
|
<h1 class="font-serif text-4xl text-ink">
|
||||||
<div
|
{person.firstName}
|
||||||
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
|
{person.lastName}
|
||||||
>
|
</h1>
|
||||||
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="w-full flex-1">
|
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
|
{#if person.alias}
|
||||||
<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>
|
<div>
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.person_label_full_name()}</span
|
>{m.form_label_alias()}</span
|
||||||
>
|
|
||||||
<span class="block font-serif text-lg text-ink"
|
|
||||||
>{person.firstName} {person.lastName}</span
|
|
||||||
>
|
>
|
||||||
|
<span class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if person.alias}
|
{#if person.birthYear || person.deathYear}
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>{m.form_label_alias()}</span
|
>
|
||||||
>
|
{#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 class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
|
</span>
|
||||||
</div>
|
<span class="block font-serif text-lg text-ink">
|
||||||
{/if}
|
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if person.birthYear || person.deathYear}
|
{#if person.notes}
|
||||||
<div>
|
<div class="md:col-span-2">
|
||||||
<span
|
<span
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
>
|
>{m.person_label_notes()}</span
|
||||||
{#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>
|
<p class="font-serif text-base whitespace-pre-wrap text-ink">
|
||||||
<span class="block font-serif text-lg text-ink">
|
{person.notes}
|
||||||
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
|
</p>
|
||||||
{/if}{#if person.deathYear}† {person.deathYear}{/if}
|
</div>
|
||||||
</span>
|
{/if}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
const mockFetch = vi.fn() as unknown as typeof fetch;
|
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());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
@@ -24,13 +26,30 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} 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.person.firstName).toBe('Hans');
|
||||||
expect(result.sentDocuments).toHaveLength(1);
|
expect(result.sentDocuments).toHaveLength(1);
|
||||||
expect(result.receivedDocuments).toEqual([]);
|
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 () => {
|
it('returns empty arrays when sent/received document APIs fail', async () => {
|
||||||
vi.mocked(createApiClient).mockReturnValue({
|
vi.mocked(createApiClient).mockReturnValue({
|
||||||
GET: vi
|
GET: vi
|
||||||
@@ -43,7 +62,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: false }, data: null })
|
.mockResolvedValueOnce({ response: { ok: false }, data: null })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} 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.sentDocuments).toEqual([]);
|
||||||
expect(result.receivedDocuments).toEqual([]);
|
expect(result.receivedDocuments).toEqual([]);
|
||||||
@@ -62,7 +81,9 @@ describe('person detail load — error paths', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} 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
|
status: 404
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -76,7 +97,9 @@ describe('person detail load — error paths', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} 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
|
status: 403
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user