diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 47582e07..1cde5715 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -916,6 +916,7 @@ "error_duplicate_relationship": "Diese Beziehung gibt es bereits.", "relation_parent_of": "Elternteil von", + "relation_child_of": "Kind von", "relation_spouse_of": "Ehegatte", "relation_sibling_of": "Geschwister", "relation_friend": "Freund", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5b352f0f..e7c23070 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -916,6 +916,7 @@ "error_duplicate_relationship": "This relationship already exists.", "relation_parent_of": "Parent of", + "relation_child_of": "Child of", "relation_spouse_of": "Spouse", "relation_sibling_of": "Sibling", "relation_friend": "Friend", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index d1f8ac85..93d7c57d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -916,6 +916,7 @@ "error_duplicate_relationship": "Esta relación ya existe.", "relation_parent_of": "Progenitor de", + "relation_child_of": "Hijo/a de", "relation_spouse_of": "Cónyuge", "relation_sibling_of": "Hermano/a", "relation_friend": "Amigo/a", diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 954f23dd..55acd69e 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -19,6 +19,7 @@ interface Props { autofocus?: boolean; required?: boolean; restrictToCorrespondentsOf?: string; + excludePersonId?: string; badge?: 'additive' | 'replace'; onchange?: (value: string) => void; onfocused?: () => void; @@ -36,6 +37,7 @@ let { autofocus = false, required = false, restrictToCorrespondentsOf, + excludePersonId, badge, onchange, onfocused @@ -61,17 +63,20 @@ $effect(() => { const typeahead = createTypeahead({ fetchUrl: async (term) => { const personId = restrictToCorrespondentsOf; + const excludeId = excludePersonId; + const filter = (results: Person[]) => + excludeId ? results.filter((p) => p.id !== excludeId) : results; if (personId) { const url = term.length >= 1 ? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}` : `/api/persons/${personId}/correspondents`; const res = await fetch(url); - return res.ok ? await res.json() : []; + return res.ok ? filter(await res.json()) : []; } if (term.length < 1) return []; const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`); - return res.ok ? await res.json() : []; + return res.ok ? filter(await res.json()) : []; }, debounceMs: 300 }); diff --git a/frontend/src/lib/components/StammbaumCard.svelte b/frontend/src/lib/components/StammbaumCard.svelte new file mode 100644 index 00000000..d188d510 --- /dev/null +++ b/frontend/src/lib/components/StammbaumCard.svelte @@ -0,0 +1,365 @@ + + +
+ +
+

+ Stammbaum & Beziehungen +

+ {#if canWrite} +
+ + +
+ {/if} +
+ + {#if relationshipError} + + {/if} + + + {#if familyMember} +
+
+ + {m.relation_label_in_tree()} +
+ + {m.relation_label_view_in_tree()} + +
+ {/if} + + +

+ {m.relation_label_direct()} +

+ {#if sortedDirect.length === 0} +

{m.person_relationships_empty()}

+ {:else} + + {/if} + + + {#if canWrite} + {#if !addFormOpen} + + {:else} +
{ + return async ({ result, update }) => { + await update(); + if (result.type === 'success') { + addFormOpen = false; + resetAddForm(); + } + }; + }} + class="mt-3 rounded-sm border border-line bg-muted/40 p-3" + > +
+ +
+ +
+ + +
+ {#if selfError} + + {/if} +
+ + +
+
+ {/if} + {/if} + + + {#if topDerived.length > 0} +
+ + {m.relation_label_derived()} + +
    + {#each topDerived as derived (derived.person.id)} +
  • + + {inferredRelationshipLabel(derived.label)} + + + {derived.person.displayName} + +
  • + {/each} +
+
+ {/if} +
diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts index c3901f8f..9658e59f 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.server.ts +++ b/frontend/src/routes/persons/[id]/edit/+page.server.ts @@ -17,9 +17,11 @@ export async function load({ params, fetch, locals }) { const { id } = params; const api = createApiClient(fetch); - const [result, aliasesResult] = await Promise.all([ + const [result, aliasesResult, relsResult, inferredResult] = await Promise.all([ api.GET('/api/persons/{id}', { params: { path: { id } } }), - api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }) + api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }), + api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }), + api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } }) ]); if (!result.response.ok) { @@ -29,7 +31,12 @@ export async function load({ params, fetch, locals }) { const person = result.data!; const personType = normalizePersonType(person.personType); - return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] }; + return { + person: { ...person, personType }, + aliases: aliasesResult.data ?? [], + relationships: relsResult.data ?? [], + inferredRelationships: inferredResult.data ?? [] + }; } export const actions = { @@ -146,5 +153,86 @@ export const actions = { } return { aliasSuccess: true }; + }, + + toggleFamilyMember: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const value = formData.get('familyMember')?.toString() === 'true'; + + const api = createApiClient(fetch); + const result = await api.PATCH('/api/persons/{id}/family-member', { + params: { path: { id: params.id } }, + body: { familyMember: value } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { relationshipError: getErrorMessage(code) }); + } + return { relationshipSuccess: true }; + }, + + addRelationship: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const relatedPersonId = formData.get('relatedPersonId')?.toString(); + const relationType = formData.get('relationType')?.toString(); + const fromYearRaw = formData.get('fromYear')?.toString().trim(); + const toYearRaw = formData.get('toYear')?.toString().trim(); + const notes = formData.get('notes')?.toString().trim() || undefined; + + if (!relatedPersonId || !relationType) { + return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); + } + if (relatedPersonId === params.id) { + return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); + } + const fromYear = fromYearRaw ? parseInt(fromYearRaw, 10) : undefined; + const toYear = toYearRaw ? parseInt(toYearRaw, 10) : undefined; + if ( + fromYear !== undefined && + toYear !== undefined && + !Number.isNaN(fromYear) && + !Number.isNaN(toYear) && + toYear < fromYear + ) { + return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); + } + + const api = createApiClient(fetch); + const result = await api.POST('/api/persons/{id}/relationships', { + params: { path: { id: params.id } }, + body: { + relatedPersonId, + relationType, + ...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}), + ...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}), + ...(notes ? { notes } : {}) + } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { relationshipError: getErrorMessage(code) }); + } + return { relationshipSuccess: true }; + }, + + deleteRelationship: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const relId = formData.get('relId')?.toString(); + if (!relId) { + return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); + } + + const api = createApiClient(fetch); + const result = await api.DELETE('/api/persons/{id}/relationships/{relId}', { + params: { path: { id: params.id, relId } } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { relationshipError: getErrorMessage(code) }); + } + return { relationshipSuccess: true }; } }; diff --git a/frontend/src/routes/persons/[id]/edit/+page.svelte b/frontend/src/routes/persons/[id]/edit/+page.svelte index b49dc471..f7dd05ce 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.svelte +++ b/frontend/src/routes/persons/[id]/edit/+page.svelte @@ -6,6 +6,7 @@ import PersonEditForm from './PersonEditForm.svelte'; import PersonEditSaveBar from './PersonEditSaveBar.svelte'; import NameHistoryEditCard from './NameHistoryEditCard.svelte'; import PersonMergePanel from '../PersonMergePanel.svelte'; +import StammbaumCard from '$lib/components/StammbaumCard.svelte'; let { data, form } = $props(); const person = $derived(data.person); @@ -35,6 +36,15 @@ const person = $derived(data.person); + + {#key person.id} {/key}