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

@@ -2,8 +2,23 @@ import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
const PAGE_SIZE = 50;
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP';
function parseType(raw: string | null): PersonType | undefined {
return raw === 'PERSON' || raw === 'INSTITUTION' || raw === 'GROUP' ? raw : undefined;
}
export async function load({ url, fetch, locals }) {
const q = url.searchParams.get('q') || '';
const page = Math.max(0, Number.parseInt(url.searchParams.get('page') ?? '0', 10) || 0);
const review =
url.searchParams.get('review') === '1' || url.searchParams.get('review') === 'true';
const type = parseType(url.searchParams.get('type'));
const familyOnly = url.searchParams.get('familyOnly') === 'true';
const hasDocuments = url.searchParams.get('hasDocuments') === 'true';
const api = createApiClient(fetch);
const canWrite =
@@ -11,15 +26,32 @@ export async function load({ url, fetch, locals }) {
g.permissions.includes('WRITE_ALL')
) ?? false;
const [personsResult, statsResult] = await Promise.all([
api.GET('/api/persons', { params: { query: { q: q || undefined } } }),
api.GET('/api/stats', {})
const filters = {
q: q || undefined,
type,
familyOnly: familyOnly || undefined,
hasDocuments: hasDocuments || undefined,
review: review || undefined,
page,
size: PAGE_SIZE
};
// The "Zu prüfen (N)" link count is the totalElements of a provisional-only query. A size=1
// page keeps the extra request cheap — we only need the count, not the rows.
const [personsResult, statsResult, reviewCountResult] = await Promise.all([
api.GET('/api/persons', { params: { query: filters } }),
api.GET('/api/stats', {}),
canWrite
? api.GET('/api/persons', { params: { query: { provisional: true, review: true, size: 1 } } })
: Promise.resolve(null)
]);
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage(undefined));
}
const result = personsResult.data!;
const stats = statsResult.response.ok
? {
totalPersons: statsResult.data!.totalPersons ?? 0,
@@ -27,5 +59,21 @@ export async function load({ url, fetch, locals }) {
}
: { totalPersons: 0, totalDocuments: 0 };
return { persons: personsResult.data!, stats, q, canWrite };
const needsReviewCount =
reviewCountResult && reviewCountResult.response.ok
? (reviewCountResult.data!.totalElements ?? 0)
: 0;
return {
persons: result.items,
totalElements: result.totalElements,
totalPages: result.totalPages,
pageNumber: result.pageNumber,
pageSize: result.pageSize,
filters: { type, familyOnly, hasDocuments, review },
needsReviewCount,
stats,
q,
canWrite
};
}