Files
familienarchiv/frontend/src/lib/person/genealogy/StammbaumCard.svelte
Marcel 491d1a015a feat(relationship): date+precision edit UI, notes, and read-view display
Regenerate api.ts for the LocalDate+DatePrecision RelationshipDTO /
RelationshipUpsertRequest and the new PUT, then migrate every caller:

- RelationshipDateField (mirrors PersonLifeDateField: DAY/MONTH/YEAR, 44px
  targets, labelled, semantic dark-mode tokens, relation_* i18n keys).
- AddRelationshipForm is now upsert-capable: an optional `relationship` prop
  pre-fills type, person, both dates+precision and notes; posts to
  ?/updateRelationship (else ?/addRelationship); the submit control disables and
  shows a progress spinner while a request is in flight (REQ-019); notes textarea
  (<=2000).
- RelationshipChip gains an accessible Edit affordance (canWrite + onEdit);
  StammbaumCard wires it, formats the date range via formatRelationshipDateRange,
  and sorts by fromDate. PersonRelationshipsCard (read view) shows the date range
  and notes; no dates -> no date line.
- persons/[id]/edit/+page.server.ts: updateRelationship action (PUT) + the
  addRelationship action reshaped to date+precision+notes (empty date omits
  precision for coherence).
- Genealogy callers fixed for the dropped year fields: familyForest spouse-order
  and StammbaumConnectors ended-edge dashing now key off fromDate/toDate.
- i18n relation_* form keys in de/en/es.

REQ-004, REQ-014, REQ-015, REQ-016, REQ-019

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:24:24 +02:00

188 lines
5.6 KiB
Svelte

<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
interface Props {
personId: string;
familyMember: boolean;
relationships: RelationshipDTO[];
inferredRelationships: InferredRelationshipWithPersonDTO[];
canWrite: boolean;
relationshipError?: string | null;
}
let {
personId,
familyMember,
relationships,
inferredRelationships,
canWrite,
relationshipError = null
}: Props = $props();
type RelationType = NonNullable<RelationshipDTO['relationType']>;
const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
const topDerived = $derived(inferredRelationships.slice(0, 5));
let editingRelId = $state<string | null>(null);
function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number {
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
if (order !== 0) return order;
// ISO dates sort lexicographically == chronologically; a missing date sorts first.
return (a.fromDate ?? '').localeCompare(b.fromDate ?? '');
}
function relationTypeOrder(t: RelationType | undefined): number {
const order: Record<string, number> = {
PARENT_OF: 1,
SPOUSE_OF: 2,
SIBLING_OF: 3,
FRIEND: 4,
COLLEAGUE: 5,
EMPLOYER: 6,
DOCTOR: 7,
NEIGHBOR: 8,
OTHER: 9
};
return order[t ?? 'OTHER'] ?? 99;
}
function dateRangeOf(rel: RelationshipDTO): string {
return formatRelationshipDateRange(
rel.fromDate,
rel.fromDatePrecision,
rel.toDate,
rel.toDatePrecision
);
}
</script>
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
<!-- Header row: heading + family-member toggle -->
<div class="mb-5 flex items-start justify-between gap-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_relationships_heading()}
</h2>
{#if canWrite}
<form method="POST" action="?/toggleFamilyMember" use:enhance>
<input type="hidden" name="familyMember" value={familyMember ? 'false' : 'true'} />
<button
type="submit"
role="switch"
aria-checked={familyMember}
aria-label={familyMember
? m.relation_toggle_remove_from_tree()
: m.relation_toggle_add_to_tree()}
class="inline-flex items-center gap-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
<span
class="relative inline-block h-4 w-7 rounded-full transition-colors {familyMember
? 'bg-primary'
: 'bg-line'}"
>
<span
class="absolute top-0.5 left-0.5 inline-block h-3 w-3 rounded-full bg-white transition-transform {familyMember
? 'translate-x-3'
: ''}"
></span>
</span>
{m.relation_label_family_member()}
</button>
</form>
{/if}
</div>
{#if relationshipError}
<p class="mb-3 text-sm text-red-700" role="alert">{relationshipError}</p>
{/if}
<!-- In-tree banner -->
{#if familyMember}
<div
class="mb-4 flex items-center justify-between rounded-sm border border-accent/30 bg-accent/10 px-3 py-2"
>
<div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full bg-accent"></span>
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_in_tree()}</span>
</div>
<a
href="/stammbaum?focus={personId}"
class="font-sans text-xs font-medium text-primary hover:underline"
>
{m.relation_label_view_in_tree()}
</a>
</div>
{/if}
<!-- Direkte Beziehungen -->
<h3 class="mb-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.relation_label_direct()}
</h3>
{#if sortedDirect.length === 0}
<p class="mb-2 text-sm text-ink-2 italic">{m.person_relationships_empty()}</p>
{:else}
<ul class="mb-2 divide-y divide-line">
{#each sortedDirect as rel (rel.id)}
<RelationshipChip
chipLabel={chipLabel(rel, personId)}
otherName={otherName(rel, personId)}
dateRange={dateRangeOf(rel)}
canWrite={canWrite}
relId={rel.id}
onEdit={canWrite ? () => (editingRelId = rel.id) : undefined}
/>
{#if editingRelId === rel.id}
<li>
<AddRelationshipForm
personId={personId}
relationship={rel}
onClose={() => (editingRelId = null)}
/>
</li>
{/if}
{/each}
</ul>
{/if}
{#if canWrite}
<AddRelationshipForm personId={personId} />
{/if}
<!-- Abgeleitete Beziehungen -->
{#if topDerived.length > 0}
<details class="mt-6">
<summary
class="cursor-pointer text-xs font-bold tracking-widest text-ink-3 uppercase select-none"
>
{m.relation_label_derived()}
</summary>
<ul class="mt-2 space-y-2">
{#each topDerived as derived (derived.person.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{inferredRelationshipLabel(derived.label)}
</span>
<a
href="/persons/{derived.person.id}"
class="min-w-0 flex-1 truncate font-serif text-sm text-ink-2 hover:underline"
>
{derived.person.displayName}
</a>
</li>
{/each}
</ul>
</details>
{/if}
</div>