feat(persons): clean filterable paginated directory with crash fix

Rewrite /persons: server-side filter chips (type, family-only, has-documents)
that AND within the clean reader default (familyMember OR documentCount > 0),
a writer-only show-all/Zu-prüfen toggle, and reused Pagination. Extract
PersonCard (fixes the null-lastName render crash and never shows a "?" initial —
provisional/UNKNOWN/"?" entries get a neutral placeholder avatar + a text+icon
"unbestätigt" badge, WCAG 1.4.1) and PersonFilterBar (44px aria-pressed chips,
role=switch toggle with the count in its accessible name). The loader applies
the reader restriction unless review=1 and surfaces a cheap needsReviewCount.
i18n keys added for de/en/es.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-27 13:55:18 +02:00
parent 67272178a9
commit 888adcb185
12 changed files with 802 additions and 87 deletions

View File

@@ -0,0 +1,66 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonCard from './PersonCard.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['PersonSummaryDTO'];
const makePerson = (overrides: Partial<Person> = {}): Person => ({
id: 'p-1',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON',
familyMember: false,
provisional: false,
documentCount: 0,
...overrides
});
afterEach(cleanup);
describe('PersonCard — confirmed person', () => {
it('renders the display name', async () => {
render(PersonCard, { props: { person: makePerson() } });
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
});
it('does not show an unconfirmed badge for a confirmed person', async () => {
render(PersonCard, { props: { person: makePerson() } });
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
});
describe('PersonCard — unconfirmed / malformed (regression: null-lastName crash)', () => {
it('renders without throwing when lastName is null', async () => {
// Before the fix, `lastName[0]` threw at render for a null lastName.
const person = makePerson({
lastName: null as unknown as string,
displayName: '?',
provisional: true
});
render(PersonCard, { props: { person } });
// No throw + the placeholder avatar (an <img>) is present, never a "?" initial.
await expect.element(page.getByText('unbestätigt')).toBeVisible();
});
it('shows an unbestätigt badge for a provisional person', async () => {
render(PersonCard, { props: { person: makePerson({ provisional: true }) } });
await expect.element(page.getByText('unbestätigt')).toBeVisible();
});
it('shows a placeholder (no "?" initial) for a "?" name', async () => {
render(PersonCard, {
props: { person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' }) }
});
await expect.element(page.getByText('unbestätigt')).toBeVisible();
});
it('treats an UNKNOWN type as unconfirmed', async () => {
render(PersonCard, {
props: { person: makePerson({ personType: 'UNKNOWN', displayName: 'Unklar' }) }
});
await expect.element(page.getByText('unbestätigt')).toBeVisible();
});
});