feat: PersonNameParser enhancements and Person model refactor (#209-#213) #215
@@ -9,11 +9,18 @@ import java.util.UUID;
|
||||
*/
|
||||
public interface PersonSummaryDTO {
|
||||
UUID getId();
|
||||
String getTitle();
|
||||
String getFirstName();
|
||||
String getLastName();
|
||||
String getPersonType();
|
||||
String getAlias();
|
||||
Integer getBirthYear();
|
||||
Integer getDeathYear();
|
||||
String getNotes();
|
||||
long getDocumentCount();
|
||||
|
||||
default String getDisplayName() {
|
||||
return org.raddatz.familienarchiv.model.DisplayNameFormatter.format(
|
||||
getTitle(), getFirstName(), getLastName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
@Size(max = 50)
|
||||
private String title;
|
||||
@Size(max = 100)
|
||||
private String firstName;
|
||||
@Size(max = 100)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
public class DisplayNameFormatter {
|
||||
|
||||
public static String format(String title, String firstName, String lastName) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (title != null) sb.append(title).append(" ");
|
||||
if (firstName != null) sb.append(firstName).append(" ");
|
||||
sb.append(lastName);
|
||||
return sb.toString().trim();
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,22 @@ public class Person {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Column(name = "title")
|
||||
private String title;
|
||||
|
||||
@Column(nullable = true)
|
||||
private String firstName;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String lastName;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "person_type", nullable = false)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private PersonType personType = PersonType.PERSON;
|
||||
|
||||
// Optional: Aliasse für die Suche (z.B. "Opa Hans")
|
||||
private String alias;
|
||||
|
||||
@@ -46,4 +54,10 @@ public class Person {
|
||||
@JsonIgnore
|
||||
@Builder.Default
|
||||
private List<PersonNameAlias> nameAliases = new ArrayList<>();
|
||||
|
||||
@Transient
|
||||
@Schema(accessMode = Schema.AccessMode.READ_ONLY, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
public String getDisplayName() {
|
||||
return DisplayNameFormatter.format(title, firstName, lastName);
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,6 @@ public enum PersonNameAliasType {
|
||||
BIRTH,
|
||||
WIDOWED,
|
||||
DIVORCED,
|
||||
MAIDEN_NAME,
|
||||
OTHER
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
public enum PersonType {
|
||||
PERSON,
|
||||
INSTITUTION,
|
||||
GROUP,
|
||||
UNKNOWN,
|
||||
SKIP
|
||||
}
|
||||
@@ -41,7 +41,7 @@ public class DocumentSpecifications {
|
||||
cb.equal(receiverRoot.get("id"), root.get("id")),
|
||||
cb.or(
|
||||
cb.like(cb.lower(receiverJoin.get("lastName")), likePattern),
|
||||
cb.like(cb.lower(receiverJoin.get("firstName")), likePattern)
|
||||
cb.like(cb.lower(cb.coalesce(receiverJoin.get("firstName"), "")), likePattern)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -74,7 +74,7 @@ public class DocumentSpecifications {
|
||||
cb.like(cb.lower(root.get("transcription")), likePattern),
|
||||
cb.like(cb.lower(root.get("location")), likePattern),
|
||||
cb.like(cb.lower(senderJoin.get("lastName")), likePattern),
|
||||
cb.like(cb.lower(senderJoin.get("firstName")), likePattern),
|
||||
cb.like(cb.lower(cb.coalesce(senderJoin.get("firstName"), "")), likePattern),
|
||||
cb.like(cb.lower(senderAliasJoin.get("lastName")), likePattern),
|
||||
cb.exists(receiverSub),
|
||||
cb.exists(receiverAliasSub),
|
||||
|
||||
@@ -16,8 +16,8 @@ import org.springframework.stereotype.Repository;
|
||||
public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
|
||||
@Query("SELECT DISTINCT p FROM Person p LEFT JOIN p.nameAliases a WHERE " +
|
||||
"LOWER(CONCAT(p.firstName,' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(CONCAT(p.lastName, ' ', p.firstName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||
@@ -35,7 +35,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// --- PersonSummaryDTO with document count ---
|
||||
|
||||
@Query(value = """
|
||||
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
@@ -46,17 +47,18 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
List<PersonSummaryDTO> findAllWithDocumentCount();
|
||||
|
||||
@Query(value = """
|
||||
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
FROM persons p
|
||||
LEFT JOIN person_name_aliases a ON a.person_id = p.id
|
||||
WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
WHERE LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
GROUP BY p.id, p.first_name, p.last_name, p.alias, p.birth_year, p.death_year, p.notes
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
@@ -98,8 +100,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
|
||||
) shared ON shared.other_id = p.id
|
||||
WHERE p.id != :personId
|
||||
AND (LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
|
||||
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:q,'%'))
|
||||
AND (LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
|
||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:q,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%')))
|
||||
GROUP BY p.id
|
||||
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import java.util.Objects;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -301,6 +302,7 @@ public class MassImportService {
|
||||
Person sender = senderRaw.isBlank() ? null : findOrCreatePerson(senderRaw);
|
||||
List<Person> receivers = PersonNameParser.parseReceivers(receiversRaw).stream()
|
||||
.map(this::findOrCreatePerson)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
||||
Tag tag = null;
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -16,14 +17,21 @@ public class PersonNameParser {
|
||||
// Known last names in this archive, longest first to avoid partial matches
|
||||
// (e.g. "de Gruyter" must be checked before any single-word name)
|
||||
static final List<String> KNOWN_LAST_NAMES = List.of(
|
||||
"von der Heide", "von Massenbach", "von Geldern", "von Gelden", "von Staa",
|
||||
"de Gruyter", "Dieckmann", "Gruber", "Müller", "Wolff", "Cram");
|
||||
|
||||
private static final Pattern GEB_PATTERN = Pattern.compile("\\s+geb\\.\\s+\\S+");
|
||||
private static final Pattern GEB_PATTERN = Pattern.compile(",?\\s*geb\\.?\\s+(.+)$");
|
||||
private static final Pattern PAREN_LAST_NAME = Pattern.compile("\\(([^)]+)\\)\\s*$");
|
||||
private static final Pattern MULTI_SEPARATOR = Pattern.compile("\\s+(?:und|u)\\s+");
|
||||
private static final Pattern SLASH_SEPARATOR = Pattern.compile("//");
|
||||
|
||||
public record SplitName(String firstName, String lastName) {}
|
||||
public record SplitName(
|
||||
String title,
|
||||
String firstName,
|
||||
String lastName,
|
||||
String maidenName,
|
||||
String annotation
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parses the "An" field from the ODS into individual normalised name strings.
|
||||
@@ -53,7 +61,14 @@ public class PersonNameParser {
|
||||
// 1. Strip "geb. Xxx" maiden-name annotations
|
||||
String cleaned = GEB_PATTERN.matcher(raw).replaceAll("").trim();
|
||||
|
||||
// 2. Extract parenthesised last name override, e.g. "(Gruber)"
|
||||
// 2. If no multi-separator present, this is a single person — leave parens
|
||||
// intact for split()'s annotation extraction
|
||||
if (!MULTI_SEPARATOR.matcher(cleaned).find()) {
|
||||
return List.of(cleaned);
|
||||
}
|
||||
|
||||
// 3. Extract parenthesised last name override, e.g. "(Gruber)"
|
||||
// Only applies to multi-person entries like "Hedi und Tutu (Gruber)"
|
||||
String sharedLastName = null;
|
||||
Matcher parenMatcher = PAREN_LAST_NAME.matcher(cleaned);
|
||||
if (parenMatcher.find()) {
|
||||
@@ -61,11 +76,6 @@ public class PersonNameParser {
|
||||
cleaned = cleaned.substring(0, parenMatcher.start()).trim();
|
||||
}
|
||||
|
||||
// 3. If no multi-separator present, this is a single person
|
||||
if (!MULTI_SEPARATOR.matcher(cleaned).find()) {
|
||||
return List.of(cleaned);
|
||||
}
|
||||
|
||||
// 4. Split on " und " / " u "
|
||||
String[] parts = MULTI_SEPARATOR.split(cleaned);
|
||||
|
||||
@@ -112,35 +122,157 @@ public class PersonNameParser {
|
||||
return nameParts;
|
||||
}
|
||||
|
||||
// --- Pipeline result records (package-private for testing) ---
|
||||
|
||||
public record MaidenNameResult(String cleaned, String maidenName) {}
|
||||
public record AnnotationResult(String cleaned, String annotation) {}
|
||||
public record TitleResult(String cleaned, String title) {}
|
||||
record NameParts(String firstName, String lastName) {}
|
||||
|
||||
/**
|
||||
* Splits a single full name string into firstName and lastName.
|
||||
* Uses known last names first; falls back to splitting on the last space.
|
||||
* Splits a single full name string into a structured SplitName.
|
||||
* Pipeline: stripMaidenName → normalizeDotCompressed → stripAnnotation → stripTitle → splitByKnownLastNameOrFallback
|
||||
*/
|
||||
public static SplitName split(String rawName) {
|
||||
if (rawName == null || rawName.isBlank()) {
|
||||
return new SplitName("?", "?");
|
||||
return new SplitName(null, "?", "?", null, null);
|
||||
}
|
||||
|
||||
String cleaned = GEB_PATTERN.matcher(rawName).replaceAll("").trim();
|
||||
MaidenNameResult maiden = stripMaidenName(rawName);
|
||||
String cleaned = maiden.cleaned();
|
||||
|
||||
// Normalize dot-compressed names: "Dr.Fr.Zarncke" -> "Dr. Fr. Zarncke"
|
||||
if (!cleaned.contains(" ") && cleaned.contains(".")) {
|
||||
cleaned = cleaned.replace(".", ". ").trim();
|
||||
cleaned = normalizeDotCompressed(cleaned);
|
||||
|
||||
AnnotationResult paren = stripAnnotation(cleaned);
|
||||
cleaned = paren.cleaned();
|
||||
|
||||
TitleResult title = stripTitle(cleaned);
|
||||
cleaned = title.cleaned();
|
||||
|
||||
NameParts parts = splitByKnownLastNameOrFallback(cleaned);
|
||||
|
||||
String firstName = parts.firstName();
|
||||
String lastName = parts.lastName();
|
||||
|
||||
// When a title was stripped and no first name could be extracted, the
|
||||
// remaining text is the lastName. "Tante Molly" -> title=Tante, lastName=Molly.
|
||||
if (title.title() != null) {
|
||||
if ("?".equals(lastName) && !cleaned.contains(" ")) {
|
||||
lastName = firstName;
|
||||
firstName = null;
|
||||
} else if (Objects.equals(firstName, lastName)) {
|
||||
firstName = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new SplitName(
|
||||
title.title(), firstName, lastName,
|
||||
maiden.maidenName(), paren.annotation()
|
||||
);
|
||||
}
|
||||
|
||||
/** Strips geb annotations and extracts the maiden name. */
|
||||
public static MaidenNameResult stripMaidenName(String input) {
|
||||
Matcher m = GEB_PATTERN.matcher(input);
|
||||
if (m.find()) {
|
||||
String cleaned = input.substring(0, m.start()).trim();
|
||||
String maidenName = m.group(1).trim();
|
||||
return new MaidenNameResult(cleaned, maidenName);
|
||||
}
|
||||
return new MaidenNameResult(input, null);
|
||||
}
|
||||
|
||||
/** Normalizes dot-compressed names: "Dr.Fr.Zarncke" → "Dr. Fr. Zarncke" */
|
||||
static String normalizeDotCompressed(String input) {
|
||||
if (!input.contains(" ") && input.contains(".")) {
|
||||
return input.replace(".", ". ").trim();
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
private static final Pattern PAREN_ANNOTATION = Pattern.compile("\\s*\\(([^)]*)\\)\\s*$");
|
||||
private static final Pattern UNCERTAIN_NAME = Pattern.compile("^(\\S+)\\s+\\?\\s*$");
|
||||
|
||||
/** Strips trailing parenthesized annotations and extracts the content. */
|
||||
public static AnnotationResult stripAnnotation(String input) {
|
||||
Matcher m = PAREN_ANNOTATION.matcher(input);
|
||||
if (!m.find()) {
|
||||
return new AnnotationResult(input, null);
|
||||
}
|
||||
String cleaned = input.substring(0, m.start()).trim();
|
||||
String rawAnnotation = m.group(1).trim();
|
||||
|
||||
Matcher uncertainMatcher = UNCERTAIN_NAME.matcher(rawAnnotation);
|
||||
if (uncertainMatcher.matches()) {
|
||||
String nameFromAnnotation = uncertainMatcher.group(1);
|
||||
cleaned = (cleaned + " " + nameFromAnnotation).trim();
|
||||
return new AnnotationResult(cleaned, "?");
|
||||
}
|
||||
|
||||
return new AnnotationResult(cleaned, rawAnnotation);
|
||||
}
|
||||
|
||||
private static final List<String> DOT_PREFIXES = List.of("Dr.", "Prof.");
|
||||
|
||||
private static final List<String> WORD_PREFIXES = List.of(
|
||||
"Frau", "Herr", "Freifrau", "Freiherr",
|
||||
"Tante", "Onkel", "Schwester", "Bruder",
|
||||
"Cousine", "Cousin", "Freundin", "Freund",
|
||||
"Mutter", "Vater", "Pastor");
|
||||
|
||||
/** Strips known title/relationship prefixes, looping for stacked titles. */
|
||||
public static TitleResult stripTitle(String input) {
|
||||
String remaining = input;
|
||||
StringBuilder titleBuilder = new StringBuilder();
|
||||
boolean found = true;
|
||||
|
||||
while (found) {
|
||||
found = false;
|
||||
|
||||
for (String prefix : DOT_PREFIXES) {
|
||||
if (remaining.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
titleBuilder.append(titleBuilder.isEmpty() ? "" : " ").append(prefix);
|
||||
remaining = remaining.substring(prefix.length()).trim();
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) continue;
|
||||
|
||||
for (String prefix : WORD_PREFIXES) {
|
||||
String lower = remaining.toLowerCase();
|
||||
if (lower.startsWith(prefix.toLowerCase() + " ") || lower.equals(prefix.toLowerCase())) {
|
||||
titleBuilder.append(titleBuilder.isEmpty() ? "" : " ").append(prefix);
|
||||
remaining = remaining.length() > prefix.length()
|
||||
? remaining.substring(prefix.length() + 1).trim()
|
||||
: "";
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (titleBuilder.isEmpty()) {
|
||||
return new TitleResult(input, null);
|
||||
}
|
||||
return new TitleResult(remaining, titleBuilder.toString());
|
||||
}
|
||||
|
||||
/** Splits a cleaned name into firstName/lastName using known last names or last-space fallback. */
|
||||
static NameParts splitByKnownLastNameOrFallback(String cleaned) {
|
||||
String lastName = findKnownLastName(cleaned);
|
||||
if (lastName != null) {
|
||||
String firstName = cleaned.substring(0, cleaned.length() - lastName.length()).trim();
|
||||
if (firstName.isBlank()) firstName = cleaned;
|
||||
return new SplitName(firstName, lastName);
|
||||
return new NameParts(firstName, lastName);
|
||||
}
|
||||
|
||||
int lastSpace = cleaned.lastIndexOf(' ');
|
||||
if (lastSpace > 0) {
|
||||
return new SplitName(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim());
|
||||
return new NameParts(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim());
|
||||
}
|
||||
|
||||
return new SplitName(cleaned, "?");
|
||||
return new NameParts(cleaned, "?");
|
||||
}
|
||||
|
||||
/** Returns the known last name that the given string ends with, or null. */
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
@@ -11,6 +14,8 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -57,16 +62,38 @@ public class PersonService {
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Transactional
|
||||
public Person findOrCreateByAlias(String rawName) {
|
||||
String alias = rawName.trim();
|
||||
PersonType type = PersonTypeClassifier.classify(alias);
|
||||
if (type == PersonType.SKIP) return null;
|
||||
|
||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.lastName(alias)
|
||||
.personType(type)
|
||||
.build());
|
||||
}
|
||||
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
return personRepository.save(Person.builder()
|
||||
Person person = personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.build());
|
||||
if (split.maidenName() != null) {
|
||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(person)
|
||||
.lastName(split.maidenName())
|
||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||
.sortOrder(nextSortOrder)
|
||||
.build());
|
||||
}
|
||||
return person;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,6 +111,7 @@ public class PersonService {
|
||||
public Person createPerson(PersonUpdateDTO dto) {
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = Person.builder()
|
||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||
.firstName(dto.getFirstName())
|
||||
.lastName(dto.getLastName())
|
||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||
@@ -111,6 +139,7 @@ public class PersonService {
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||
person.setFirstName(dto.getFirstName());
|
||||
person.setLastName(dto.getLastName());
|
||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
|
||||
public class PersonTypeClassifier {
|
||||
|
||||
private static final List<String> SKIP_KEYWORDS = List.of(
|
||||
"Briefumschlag", "Kondolenzbriefe", "Hochzeitsgedicht");
|
||||
|
||||
private static final List<String> INSTITUTION_START = List.of(
|
||||
"Firma", "Architekt");
|
||||
|
||||
private static final List<String> INSTITUTION_END = List.of(
|
||||
"GmbH");
|
||||
|
||||
private static final List<String> GROUP_START = List.of(
|
||||
"Familie", "Comité", "Comite", "Geschwister", "Gesellschafter",
|
||||
"Garde", "Mitarbeiter");
|
||||
|
||||
private static final List<String> GROUP_CONTAINS = List.of(
|
||||
"Eltern", "Kinder", "Schwiegereltern");
|
||||
|
||||
public static PersonType classify(String rawName) {
|
||||
if (rawName == null || rawName.isBlank()) return PersonType.PERSON;
|
||||
|
||||
String lower = rawName.trim().toLowerCase();
|
||||
|
||||
for (String keyword : SKIP_KEYWORDS) {
|
||||
if (lower.startsWith(keyword.toLowerCase())) return PersonType.SKIP;
|
||||
}
|
||||
|
||||
for (String keyword : INSTITUTION_START) {
|
||||
if (lower.startsWith(keyword.toLowerCase())) return PersonType.INSTITUTION;
|
||||
}
|
||||
for (String keyword : INSTITUTION_END) {
|
||||
if (lower.endsWith(keyword.toLowerCase())) return PersonType.INSTITUTION;
|
||||
}
|
||||
if (lower.endsWith(" co") || lower.endsWith(" co.")) return PersonType.INSTITUTION;
|
||||
|
||||
for (String keyword : GROUP_START) {
|
||||
if (lower.startsWith(keyword.toLowerCase())) return PersonType.GROUP;
|
||||
}
|
||||
for (String keyword : GROUP_CONTAINS) {
|
||||
if (containsWord(lower, keyword.toLowerCase())) return PersonType.GROUP;
|
||||
}
|
||||
|
||||
return PersonType.PERSON;
|
||||
}
|
||||
|
||||
private static boolean containsWord(String text, String word) {
|
||||
int fromIndex = 0;
|
||||
while (true) {
|
||||
int idx = text.indexOf(word, fromIndex);
|
||||
if (idx < 0) return false;
|
||||
boolean startOk = idx == 0 || !Character.isLetter(text.charAt(idx - 1));
|
||||
int end = idx + word.length();
|
||||
boolean endOk = end >= text.length() || !Character.isLetter(text.charAt(end));
|
||||
if (startOk && endOk) return true;
|
||||
fromIndex = idx + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add title column for honorifics/salutations (Dr., Tante, Frau, etc.)
|
||||
ALTER TABLE persons ADD COLUMN title VARCHAR(50);
|
||||
|
||||
-- Add person_type column to distinguish persons from institutions/groups.
|
||||
-- SKIP is intentionally omitted: it exists in the Java enum for parse-time
|
||||
-- filtering but must never be persisted. The CHECK constraint enforces this.
|
||||
ALTER TABLE persons ADD COLUMN person_type VARCHAR(20) NOT NULL DEFAULT 'PERSON';
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_type
|
||||
CHECK (person_type IN ('PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'));
|
||||
|
||||
-- Make first_name nullable for non-person entities (institutions, groups)
|
||||
ALTER TABLE persons ALTER COLUMN first_name DROP NOT NULL;
|
||||
@@ -73,8 +73,10 @@ class PersonControllerTest {
|
||||
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||
return new PersonSummaryDTO() {
|
||||
public java.util.UUID getId() { return UUID.randomUUID(); }
|
||||
public String getTitle() { return null; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public String getLastName() { return lastName; }
|
||||
public String getPersonType() { return "PERSON"; }
|
||||
public String getAlias() { return null; }
|
||||
public Integer getBirthYear() { return null; }
|
||||
public Integer getDeathYear() { return null; }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PersonTest {
|
||||
|
||||
@Test
|
||||
void getDisplayName_withAllFields() {
|
||||
Person person = Person.builder()
|
||||
.title("Dr.")
|
||||
.firstName("Walter")
|
||||
.lastName("de Gruyter")
|
||||
.build();
|
||||
assertThat(person.getDisplayName()).isEqualTo("Dr. Walter de Gruyter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDisplayName_withoutTitle() {
|
||||
Person person = Person.builder()
|
||||
.firstName("Clara")
|
||||
.lastName("Cram")
|
||||
.build();
|
||||
assertThat(person.getDisplayName()).isEqualTo("Clara Cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDisplayName_withNullFirstName() {
|
||||
Person person = Person.builder()
|
||||
.lastName("Gesellschafter des Verlages")
|
||||
.build();
|
||||
assertThat(person.getDisplayName()).isEqualTo("Gesellschafter des Verlages");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDisplayName_withTitleAndNullFirstName() {
|
||||
Person person = Person.builder()
|
||||
.title("Tante")
|
||||
.lastName("Molly")
|
||||
.build();
|
||||
assertThat(person.getDisplayName()).isEqualTo("Tante Molly");
|
||||
}
|
||||
|
||||
@Test
|
||||
void personType_defaultsToPerson() {
|
||||
Person person = Person.builder()
|
||||
.firstName("Clara")
|
||||
.lastName("Cram")
|
||||
.build();
|
||||
assertThat(person.getPersonType()).isEqualTo(PersonType.PERSON);
|
||||
}
|
||||
}
|
||||
@@ -440,4 +440,26 @@ class PersonRepositoryTest {
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
// ─── null firstName handling ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchByName_findsPersonWithNullFirstName() {
|
||||
personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());
|
||||
|
||||
List<Person> result = personRepository.searchByName("Gesellschafter");
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithDocumentCount_findsPersonWithNullFirstName() {
|
||||
personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());
|
||||
|
||||
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Gesellschafter");
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -22,6 +24,30 @@ class PersonNameParserTest {
|
||||
.containsExactly("Eugenie de Gruyter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void singlePerson_annotationParenPreserved() {
|
||||
assertThat(PersonNameParser.parseReceivers("Clara de Gruyter(*1871)"))
|
||||
.containsExactly("Clara de Gruyter(*1871)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void singlePerson_nicknameParenPreserved() {
|
||||
assertThat(PersonNameParser.parseReceivers("Gertrud D.(Tuttu)"))
|
||||
.containsExactly("Gertrud D.(Tuttu)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void gebAnnotation_noDot_multiWord_stripped() {
|
||||
assertThat(PersonNameParser.parseReceivers("Ella Dieckmann, geb de Gruyter"))
|
||||
.containsExactly("Ella Dieckmann");
|
||||
}
|
||||
|
||||
@Test
|
||||
void gebAnnotation_noDot_singleWord_stripped() {
|
||||
assertThat(PersonNameParser.parseReceivers("Elise Rockstroh geb Sintenis"))
|
||||
.containsExactly("Elise Rockstroh");
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoFirstNames_sharedKnownLastName_und() {
|
||||
assertThat(PersonNameParser.parseReceivers("Walter und Eugenie de Gruyter"))
|
||||
@@ -81,6 +107,9 @@ class PersonNameParserTest {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter");
|
||||
assertThat(result.firstName()).isEqualTo("Walter");
|
||||
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
||||
assertThat(result.title()).isNull();
|
||||
assertThat(result.maidenName()).isNull();
|
||||
assertThat(result.annotation()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -107,22 +136,31 @@ class PersonNameParserTest {
|
||||
@Test
|
||||
void split_gebAnnotation_stripped() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Müller");
|
||||
assertThat(result.title()).isNull();
|
||||
assertThat(result.firstName()).isEqualTo("Eugenie");
|
||||
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
||||
assertThat(result.maidenName()).isEqualTo("Müller");
|
||||
assertThat(result.annotation()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_null_returnsPlaceholder() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split(null);
|
||||
assertThat(result.title()).isNull();
|
||||
assertThat(result.firstName()).isEqualTo("?");
|
||||
assertThat(result.lastName()).isEqualTo("?");
|
||||
assertThat(result.maidenName()).isNull();
|
||||
assertThat(result.annotation()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_blank_returnsPlaceholder() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split(" ");
|
||||
assertThat(result.title()).isNull();
|
||||
assertThat(result.firstName()).isEqualTo("?");
|
||||
assertThat(result.lastName()).isEqualTo("?");
|
||||
assertThat(result.maidenName()).isNull();
|
||||
assertThat(result.annotation()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -152,14 +190,16 @@ class PersonNameParserTest {
|
||||
@Test
|
||||
void split_dotCompressed_titleFirstNameLastName() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Dr.Fr.Zarncke");
|
||||
assertThat(result.firstName()).isEqualTo("Dr. Fr.");
|
||||
assertThat(result.title()).isEqualTo("Dr.");
|
||||
assertThat(result.firstName()).isEqualTo("Fr.");
|
||||
assertThat(result.lastName()).isEqualTo("Zarncke");
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_dotCompressed_titleAndLastName() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Dr.Zarnke");
|
||||
assertThat(result.firstName()).isEqualTo("Dr.");
|
||||
assertThat(result.title()).isEqualTo("Dr.");
|
||||
assertThat(result.firstName()).isNull();
|
||||
assertThat(result.lastName()).isEqualTo("Zarnke");
|
||||
}
|
||||
|
||||
@@ -172,7 +212,8 @@ class PersonNameParserTest {
|
||||
@Test
|
||||
void split_alreadySpacedDotName_noDoubleSpacing() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Dr. Fr. Zarncke");
|
||||
assertThat(result.firstName()).isEqualTo("Dr. Fr.");
|
||||
assertThat(result.title()).isEqualTo("Dr.");
|
||||
assertThat(result.firstName()).isEqualTo("Fr.");
|
||||
assertThat(result.lastName()).isEqualTo("Zarncke");
|
||||
}
|
||||
|
||||
@@ -244,4 +285,256 @@ class PersonNameParserTest {
|
||||
List<String> result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
|
||||
assertThat(result).containsExactlyInAnyOrder("Müller", "Herbert de Gruyter");
|
||||
}
|
||||
|
||||
// --- pipeline pass-through methods ---
|
||||
|
||||
@Test
|
||||
void stripMaidenName_isPassthrough() {
|
||||
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Walter de Gruyter");
|
||||
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
|
||||
assertThat(result.maidenName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripAnnotation_noParens_returnsNull() {
|
||||
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Walter de Gruyter");
|
||||
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
|
||||
assertThat(result.annotation()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripAnnotation_birthYear_noSpace() {
|
||||
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Clara de Gruyter(*1871)");
|
||||
assertThat(result.cleaned()).isEqualTo("Clara de Gruyter");
|
||||
assertThat(result.annotation()).isEqualTo("*1871");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripAnnotation_uncertainty_withSpace() {
|
||||
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Ernst Kurmany (?)");
|
||||
assertThat(result.cleaned()).isEqualTo("Ernst Kurmany");
|
||||
assertThat(result.annotation()).isEqualTo("?");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripAnnotation_nickname_noSpace() {
|
||||
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Gertrud D.(Tuttu)");
|
||||
assertThat(result.cleaned()).isEqualTo("Gertrud D.");
|
||||
assertThat(result.annotation()).isEqualTo("Tuttu");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripAnnotation_uncertainName_extractsNameBack() {
|
||||
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Richard (Quast ? )");
|
||||
assertThat(result.cleaned()).isEqualTo("Richard Quast");
|
||||
assertThat(result.annotation()).isEqualTo("?");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripAnnotation_onlyParen_returnsPlaceholder() {
|
||||
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("(OnlyParen)");
|
||||
assertThat(result.cleaned()).isEmpty();
|
||||
assertThat(result.annotation()).isEqualTo("OnlyParen");
|
||||
}
|
||||
|
||||
// --- split — annotation extraction end-to-end ---
|
||||
|
||||
@Test
|
||||
void split_birthYearAnnotation_extracted() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Clara de Gruyter(*1871)");
|
||||
assertThat(result.firstName()).isEqualTo("Clara");
|
||||
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
||||
assertThat(result.annotation()).isEqualTo("*1871");
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_uncertainName_extractsLastName() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Richard (Quast ? )");
|
||||
assertThat(result.firstName()).isEqualTo("Richard");
|
||||
assertThat(result.lastName()).isEqualTo("Quast");
|
||||
assertThat(result.annotation()).isEqualTo("?");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_noPrefix_returnsNull() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Walter de Gruyter");
|
||||
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
|
||||
assertThat(result.title()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_tante() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Tante Molly");
|
||||
assertThat(result.cleaned()).isEqualTo("Molly");
|
||||
assertThat(result.title()).isEqualTo("Tante");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_schwester() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Schwester Hanni");
|
||||
assertThat(result.cleaned()).isEqualTo("Hanni");
|
||||
assertThat(result.title()).isEqualTo("Schwester");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_frau() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Frau Bakker");
|
||||
assertThat(result.cleaned()).isEqualTo("Bakker");
|
||||
assertThat(result.title()).isEqualTo("Frau");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_cousine_withFullName() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Cousine Emmy Haniel");
|
||||
assertThat(result.cleaned()).isEqualTo("Emmy Haniel");
|
||||
assertThat(result.title()).isEqualTo("Cousine");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_freifrau() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Freifrau von Massenbach");
|
||||
assertThat(result.cleaned()).isEqualTo("von Massenbach");
|
||||
assertThat(result.title()).isEqualTo("Freifrau");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_dotPrefix_withSpace() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Dr. Sattelmacher");
|
||||
assertThat(result.cleaned()).isEqualTo("Sattelmacher");
|
||||
assertThat(result.title()).isEqualTo("Dr.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_dotPrefix_noSpace() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Dr.von Gelden");
|
||||
assertThat(result.cleaned()).isEqualTo("von Gelden");
|
||||
assertThat(result.title()).isEqualTo("Dr.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripTitle_stacked_profDr() {
|
||||
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Prof. Dr. Muller");
|
||||
assertThat(result.cleaned()).isEqualTo("Muller");
|
||||
assertThat(result.title()).isEqualTo("Prof. Dr.");
|
||||
}
|
||||
|
||||
// --- split — title extraction end-to-end ---
|
||||
|
||||
@Test
|
||||
void split_tante_setsTitle_firstNameNull() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Tante Molly");
|
||||
assertThat(result.title()).isEqualTo("Tante");
|
||||
assertThat(result.firstName()).isNull();
|
||||
assertThat(result.lastName()).isEqualTo("Molly");
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_dotTitle_afterDotNorm() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Dr.Fr.Zarncke");
|
||||
assertThat(result.title()).isEqualTo("Dr.");
|
||||
assertThat(result.firstName()).isEqualTo("Fr.");
|
||||
assertThat(result.lastName()).isEqualTo("Zarncke");
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_dotTitle_noSpace_vonLastName() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Dr.von Gelden");
|
||||
assertThat(result.title()).isEqualTo("Dr.");
|
||||
assertThat(result.firstName()).isNull();
|
||||
assertThat(result.lastName()).isEqualTo("von Gelden");
|
||||
}
|
||||
|
||||
// --- regression: non-prefixes not stripped ---
|
||||
|
||||
@Test
|
||||
void split_walter_noTitleStrip() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter");
|
||||
assertThat(result.title()).isNull();
|
||||
assertThat(result.firstName()).isEqualTo("Walter");
|
||||
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_conrad_vonGeldern_noTitleStrip() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Conrad von Geldern");
|
||||
assertThat(result.title()).isNull();
|
||||
assertThat(result.firstName()).isEqualTo("Conrad");
|
||||
assertThat(result.lastName()).isEqualTo("von Geldern");
|
||||
}
|
||||
|
||||
// --- stripMaidenName — maiden name extraction ---
|
||||
|
||||
@Test
|
||||
void stripMaidenName_standardDot_singleWord() {
|
||||
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Eugenie de Gruyter geb. Muller");
|
||||
assertThat(result.cleaned()).isEqualTo("Eugenie de Gruyter");
|
||||
assertThat(result.maidenName()).isEqualTo("Muller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripMaidenName_dot_multiWordMaidenName() {
|
||||
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Clara Cram geb. de Gruyter");
|
||||
assertThat(result.cleaned()).isEqualTo("Clara Cram");
|
||||
assertThat(result.maidenName()).isEqualTo("de Gruyter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripMaidenName_commaPrefix_noDot_multiWord() {
|
||||
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Ella Dieckmann, geb de Gruyter");
|
||||
assertThat(result.cleaned()).isEqualTo("Ella Dieckmann");
|
||||
assertThat(result.maidenName()).isEqualTo("de Gruyter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripMaidenName_noDot_singleWord() {
|
||||
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Elise Rockstroh geb Sintenis");
|
||||
assertThat(result.cleaned()).isEqualTo("Elise Rockstroh");
|
||||
assertThat(result.maidenName()).isEqualTo("Sintenis");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripMaidenName_noDot_noMarriedLastName() {
|
||||
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Elisabeth geb Fernow");
|
||||
assertThat(result.cleaned()).isEqualTo("Elisabeth");
|
||||
assertThat(result.maidenName()).isEqualTo("Fernow");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripMaidenName_noGeb_returnsNullMaidenName() {
|
||||
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Walter de Gruyter");
|
||||
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
|
||||
assertThat(result.maidenName()).isNull();
|
||||
}
|
||||
|
||||
// --- split — maiden name extraction end-to-end ---
|
||||
|
||||
@Test
|
||||
void split_gebDot_extractsMaidenName() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Muller");
|
||||
assertThat(result.firstName()).isEqualTo("Eugenie");
|
||||
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
||||
assertThat(result.maidenName()).isEqualTo("Muller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void split_gebNoDot_multiWordMaidenName() {
|
||||
PersonNameParser.SplitName result = PersonNameParser.split("Clara Cram geb. de Gruyter");
|
||||
assertThat(result.firstName()).isEqualTo("Clara");
|
||||
assertThat(result.lastName()).isEqualTo("Cram");
|
||||
assertThat(result.maidenName()).isEqualTo("de Gruyter");
|
||||
}
|
||||
|
||||
// --- enum values ---
|
||||
|
||||
@Test
|
||||
void personType_hasFiveValues() {
|
||||
assertThat(PersonType.values()).containsExactly(
|
||||
PersonType.PERSON, PersonType.INSTITUTION, PersonType.GROUP,
|
||||
PersonType.UNKNOWN, PersonType.SKIP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void personNameAliasType_includesMaidenName() {
|
||||
assertThat(PersonNameAliasType.valueOf("MAIDEN_NAME")).isEqualTo(PersonNameAliasType.MAIDEN_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class PersonServiceIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired PersonService personService;
|
||||
@Autowired PersonRepository personRepository;
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
|
||||
Person result = personService.findOrCreateByAlias("Briefumschlag aus Java");
|
||||
|
||||
assertThat(result).isNull();
|
||||
assertThat(personRepository.findAll()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_institutionStoresFullNameInLastName() {
|
||||
Person result = personService.findOrCreateByAlias("Arthur Collignon GmbH");
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||
assertThat(result.getFirstName()).isNull();
|
||||
assertThat(result.getLastName()).isEqualTo("Arthur Collignon GmbH");
|
||||
assertThat(result.getDisplayName()).isEqualTo("Arthur Collignon GmbH");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_groupStoresFullNameInLastName() {
|
||||
Person result = personService.findOrCreateByAlias("Geschwister de Gruyter");
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.GROUP);
|
||||
assertThat(result.getFirstName()).isNull();
|
||||
assertThat(result.getLastName()).isEqualTo("Geschwister de Gruyter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_personSplitsNameNormally() {
|
||||
Person result = personService.findOrCreateByAlias("Clara Cram");
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.PERSON);
|
||||
assertThat(result.getFirstName()).isEqualTo("Clara");
|
||||
assertThat(result.getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -201,6 +202,75 @@ class PersonServiceTest {
|
||||
verify(personRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
|
||||
String alias = "Clara Cram geb. de Gruyter";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
|
||||
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
|
||||
verify(aliasRepository).save(argThat(a ->
|
||||
a.getLastName().equals("de Gruyter") &&
|
||||
a.getType() == PersonNameAliasType.MAIDEN_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsNull_whenSkip() {
|
||||
Person result = personService.findOrCreateByAlias("Briefumschlag aus Java");
|
||||
assertThat(result).isNull();
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
|
||||
String alias = "Arthur Collignon GmbH";
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> {
|
||||
Person p = inv.getArgument(0);
|
||||
p.setId(UUID.randomUUID());
|
||||
return p;
|
||||
});
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||
assertThat(result.getFirstName()).isNull();
|
||||
assertThat(result.getLastName()).isEqualTo("Arthur Collignon GmbH");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
|
||||
String alias = "Geschwister de Gruyter";
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> {
|
||||
Person p = inv.getArgument(0);
|
||||
p.setId(UUID.randomUUID());
|
||||
return p;
|
||||
});
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.GROUP);
|
||||
assertThat(result.getFirstName()).isNull();
|
||||
assertThat(result.getLastName()).isEqualTo("Geschwister de Gruyter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_noAlias_whenNoGeb() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
|
||||
verify(aliasRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_trimsInput() {
|
||||
String alias = " Clara Cram ";
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PersonTypeClassifierTest {
|
||||
|
||||
// --- SKIP ---
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"'Briefumschlag aus Java', SKIP",
|
||||
"'Kondolenzbriefe zum Tod von Walter de Gruyter', SKIP",
|
||||
"'Hochzeitsgedicht fur Paul u Luise de Gruyter', SKIP"
|
||||
})
|
||||
void classify_skipEntries(String input, PersonType expected) {
|
||||
assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
// --- INSTITUTION ---
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"'Arthur Collignon GmbH', INSTITUTION",
|
||||
"'Firma Auschrath', INSTITUTION",
|
||||
"'Westermann u Co', INSTITUTION",
|
||||
"'Architekt Korschelt u Renker', INSTITUTION"
|
||||
})
|
||||
void classify_institutionEntries(String input, PersonType expected) {
|
||||
assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
// --- GROUP ---
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"'Comite der Abschiedsfeier', GROUP",
|
||||
"'Comité zur Errichtung eines Heine-Denkmals', GROUP",
|
||||
"'Garde du Corps', GROUP",
|
||||
"'Geschwister de Gruyter', GROUP",
|
||||
"'Gesellschafter des Verlages', GROUP",
|
||||
"'Ella de Gruyters Eltern', GROUP",
|
||||
"'Eugenie de Gruyters Kinder', GROUP",
|
||||
"'Hilde de Gruyters Schwiegereltern', GROUP",
|
||||
"'Eltern Muller', GROUP",
|
||||
"'Familie Cram', GROUP",
|
||||
"'Familie Hasenvlever', GROUP",
|
||||
"'Mitarbeiter Verlag', GROUP",
|
||||
"'Mitarbeiter Druckerei TrebbinClara Cram', GROUP",
|
||||
"'Mitarbeiter Kunstverlag Mu', GROUP"
|
||||
})
|
||||
void classify_groupEntries(String input, PersonType expected) {
|
||||
assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
// --- PERSON (default) ---
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"'Walter de Gruyter', PERSON",
|
||||
"'Clara Cram', PERSON",
|
||||
"'Eugenie de Gruyter geb. Müller', PERSON",
|
||||
"'Dr. Firma Mueller', PERSON"
|
||||
})
|
||||
void classify_personEntries(String input, PersonType expected) {
|
||||
assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
@Test
|
||||
void classify_null_returnsPerson() {
|
||||
assertThat(PersonTypeClassifier.classify(null)).isEqualTo(PersonType.PERSON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classify_blank_returnsPerson() {
|
||||
assertThat(PersonTypeClassifier.classify(" ")).isEqualTo(PersonType.PERSON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classify_caseInsensitive() {
|
||||
assertThat(PersonTypeClassifier.classify("firma auschrath")).isEqualTo(PersonType.INSTITUTION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classify_containsWord_findsSecondOccurrence() {
|
||||
assertThat(PersonTypeClassifier.classify("Nachbareltern Eltern")).isEqualTo(PersonType.GROUP);
|
||||
}
|
||||
}
|
||||
@@ -485,7 +485,12 @@
|
||||
"person_alias_type_BIRTH": "geborene/r",
|
||||
"person_alias_type_WIDOWED": "verwitwete/r",
|
||||
"person_alias_type_DIVORCED": "geschiedene/r",
|
||||
"person_alias_type_MAIDEN_NAME": "Geburtsname",
|
||||
"person_alias_type_OTHER": "Sonstiger Name",
|
||||
"person_type_PERSON": "Person",
|
||||
"person_type_INSTITUTION": "Institution",
|
||||
"person_type_GROUP": "Gruppe",
|
||||
"person_type_UNKNOWN": "Unbekannt",
|
||||
"person_alias_add_heading": "Name hinzufuegen",
|
||||
"person_alias_label_type": "Art",
|
||||
"person_alias_label_last_name": "Nachname",
|
||||
|
||||
@@ -485,7 +485,12 @@
|
||||
"person_alias_type_BIRTH": "Birth name",
|
||||
"person_alias_type_WIDOWED": "Name as widow/widower",
|
||||
"person_alias_type_DIVORCED": "Name after divorce",
|
||||
"person_alias_type_MAIDEN_NAME": "Maiden name",
|
||||
"person_alias_type_OTHER": "Other name",
|
||||
"person_type_PERSON": "Person",
|
||||
"person_type_INSTITUTION": "Institution",
|
||||
"person_type_GROUP": "Group",
|
||||
"person_type_UNKNOWN": "Unknown",
|
||||
"person_alias_add_heading": "Add name",
|
||||
"person_alias_label_type": "Type",
|
||||
"person_alias_label_last_name": "Last name",
|
||||
|
||||
@@ -485,7 +485,12 @@
|
||||
"person_alias_type_BIRTH": "Nombre de nacimiento",
|
||||
"person_alias_type_WIDOWED": "Nombre como viuda/viudo",
|
||||
"person_alias_type_DIVORCED": "Nombre tras el divorcio",
|
||||
"person_alias_type_MAIDEN_NAME": "Apellido de soltera",
|
||||
"person_alias_type_OTHER": "Otro nombre",
|
||||
"person_type_PERSON": "Persona",
|
||||
"person_type_INSTITUTION": "Institución",
|
||||
"person_type_GROUP": "Grupo",
|
||||
"person_type_UNKNOWN": "Desconocido",
|
||||
"person_alias_add_heading": "Agregar nombre",
|
||||
"person_alias_label_type": "Tipo",
|
||||
"person_alias_label_last_name": "Apellido",
|
||||
|
||||
@@ -7,7 +7,7 @@ type Document = {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
sender?: { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
};
|
||||
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||
import { personAvatarColor } from '$lib/utils/personFormat';
|
||||
import { getInitials as calcInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Props = {
|
||||
@@ -33,11 +33,11 @@ let showAllReceivers = $state(false);
|
||||
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
||||
|
||||
function getInitials(person: Person): string {
|
||||
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
|
||||
return calcInitials(person);
|
||||
}
|
||||
|
||||
function getFullName(person: Person): string {
|
||||
return `${person.firstName} ${person.lastName}`;
|
||||
return person.displayName;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' };
|
||||
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller', displayName: 'Karl Müller' };
|
||||
const receivers = [
|
||||
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt' },
|
||||
{ id: 'r2', firstName: 'Hans', lastName: 'Weber' }
|
||||
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
|
||||
{ id: 'r2', firstName: 'Hans', lastName: 'Weber', displayName: 'Hans Weber' }
|
||||
];
|
||||
const tags = [
|
||||
{ id: 't1', name: 'Familienbrief' },
|
||||
|
||||
@@ -7,7 +7,7 @@ import PersonChipRow from './PersonChipRow.svelte';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Doc = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
|
||||
type Props = {
|
||||
extraCount: number;
|
||||
@@ -67,8 +67,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
href="/persons/{person.id}"
|
||||
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
{person.displayName}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { abbreviateName, personAvatarColor } from '$lib/utils/personFormat';
|
||||
import { abbreviateName, getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
|
||||
type Props = {
|
||||
person: Person;
|
||||
@@ -10,13 +10,9 @@ type Props = {
|
||||
|
||||
let { person, abbreviated }: Props = $props();
|
||||
|
||||
const displayName = $derived(
|
||||
abbreviated ? abbreviateName(person) : `${person.firstName} ${person.lastName}`
|
||||
);
|
||||
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
|
||||
const avatarColor = $derived(personAvatarColor(person.id));
|
||||
const initials = $derived(
|
||||
`${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase()
|
||||
);
|
||||
const initials = $derived(getInitials(person));
|
||||
</script>
|
||||
|
||||
<a
|
||||
@@ -30,5 +26,5 @@ const initials = $derived(
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span class="text-[14px] font-semibold text-ink">{displayName}</span>
|
||||
<span class="text-[14px] font-semibold text-ink">{name}</span>
|
||||
</a>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import PersonChip from './PersonChip.svelte';
|
||||
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
|
||||
type Props = {
|
||||
sender: Person | null | undefined;
|
||||
|
||||
@@ -73,8 +73,7 @@ function removePerson(id: string | undefined) {
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
{person.displayName}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
@@ -121,7 +120,7 @@ function removePerson(id: string | undefined) {
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{person.lastName}, {person.firstName}
|
||||
{person.displayName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -7,9 +7,21 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const PERSONS = [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
|
||||
{ id: '3', firstName: 'Karl', lastName: 'König' }
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
|
||||
];
|
||||
|
||||
function mockFetch(persons = PERSONS) {
|
||||
@@ -45,8 +57,20 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
it('renders pre-selected persons as chips', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
@@ -57,8 +81,20 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
it('renders hidden inputs for each selected person', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
await tick();
|
||||
@@ -70,7 +106,15 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
|
||||
it('hides the placeholder when persons are selected', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
|
||||
selectedPersons: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -85,7 +129,7 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
await page.screenshot({
|
||||
@@ -100,11 +144,11 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Musterfrau, Anna').click();
|
||||
await page.getByText('Anna Musterfrau').click();
|
||||
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
@@ -116,22 +160,41 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
it('filters already-selected persons from search results', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
|
||||
selectedPersons: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
|
||||
// Chip still shows "Max Mustermann" but the dropdown item (role=button) must be filtered out
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
||||
.not.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: 'Anna Musterfrau' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a result with Enter key', async () => {
|
||||
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
|
||||
mockFetch([
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]);
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -142,8 +205,20 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
// Buttons have aria-label="Entfernen"
|
||||
@@ -156,8 +231,20 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
it('removes the corresponding hidden input when a chip is removed', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
await page.getByRole('button', { name: 'Entfernen' }).first().click();
|
||||
@@ -177,9 +264,9 @@ describe('PersonMultiSelect – click outside', () => {
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
76
frontend/src/lib/components/PersonTypeBadge.svelte
Normal file
76
frontend/src/lib/components/PersonTypeBadge.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
personType: string;
|
||||
}
|
||||
|
||||
let { personType }: Props = $props();
|
||||
|
||||
const config = $derived.by(() => {
|
||||
switch (personType) {
|
||||
case 'INSTITUTION':
|
||||
return { label: m.person_type_INSTITUTION(), icon: 'building' as const };
|
||||
case 'GROUP':
|
||||
return { label: m.person_type_GROUP(), icon: 'people' as const };
|
||||
case 'UNKNOWN':
|
||||
return { label: m.person_type_UNKNOWN(), icon: 'question' as const };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if config}
|
||||
<span
|
||||
class="badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
|
||||
class:badge-institution={personType === 'INSTITUTION'}
|
||||
class:badge-group={personType === 'GROUP'}
|
||||
class:badge-unknown={personType === 'UNKNOWN'}
|
||||
>
|
||||
{#if config.icon === 'building'}
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
|
||||
/>
|
||||
</svg>
|
||||
{:else if config.icon === 'people'}
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{config.label}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.badge-institution {
|
||||
background-color: var(--c-badge-institution-bg);
|
||||
color: var(--c-badge-institution-text);
|
||||
border-color: var(--c-badge-institution-border);
|
||||
}
|
||||
.badge-group {
|
||||
background-color: var(--c-badge-group-bg);
|
||||
color: var(--c-badge-group-text);
|
||||
border-color: var(--c-badge-group-border);
|
||||
}
|
||||
.badge-unknown {
|
||||
background-color: var(--c-badge-unknown-bg);
|
||||
color: var(--c-badge-unknown-text);
|
||||
border-color: var(--c-badge-unknown-border);
|
||||
}
|
||||
</style>
|
||||
@@ -117,7 +117,7 @@ function handleFocus() {
|
||||
|
||||
function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
searchTerm = person.displayName;
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
@@ -166,7 +166,7 @@ function selectPerson(person: Person) {
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.lastName}, {person.firstName}
|
||||
{person.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,20 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const PERSONS = [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
];
|
||||
|
||||
function mockFetchWithPersons(persons = PERSONS) {
|
||||
@@ -76,8 +88,8 @@ describe('PersonTypeahead – search', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
|
||||
});
|
||||
|
||||
@@ -105,7 +117,7 @@ describe('PersonTypeahead – search', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +134,7 @@ describe('PersonTypeahead – selection', () => {
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
|
||||
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
||||
.not.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||
});
|
||||
@@ -152,7 +164,15 @@ describe('PersonTypeahead – selection', () => {
|
||||
});
|
||||
|
||||
it('selects a result with Enter key', async () => {
|
||||
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
|
||||
mockFetchWithPersons([
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
@@ -218,7 +238,7 @@ describe('PersonTypeahead – correspondent mode', () => {
|
||||
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
||||
await waitForDebounce();
|
||||
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses correspondents endpoint with q param when typing', async () => {
|
||||
@@ -259,9 +279,9 @@ describe('PersonTypeahead – click outside', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -952,14 +952,18 @@ export interface components {
|
||||
Person: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
firstName: string;
|
||||
title?: string;
|
||||
firstName?: string;
|
||||
lastName: string;
|
||||
/** @enum {string} */
|
||||
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
readonly displayName: string;
|
||||
};
|
||||
DocumentUpdateDTO: {
|
||||
title?: string;
|
||||
@@ -1047,19 +1051,18 @@ export interface components {
|
||||
newPassword?: string;
|
||||
};
|
||||
PersonNameAliasDTO: {
|
||||
lastName?: string;
|
||||
lastName: string;
|
||||
firstName?: string;
|
||||
/** @enum {string} */
|
||||
type?: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
|
||||
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "MAIDEN_NAME" | "OTHER";
|
||||
};
|
||||
PersonNameAlias: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
person?: components["schemas"]["Person"];
|
||||
lastName: string;
|
||||
firstName?: string;
|
||||
/** @enum {string} */
|
||||
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
|
||||
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "MAIDEN_NAME" | "OTHER";
|
||||
/** Format: int32 */
|
||||
sortOrder: number;
|
||||
/** Format: date-time */
|
||||
@@ -1203,8 +1206,10 @@ export interface components {
|
||||
totalDocuments?: number;
|
||||
};
|
||||
PersonSummaryDTO: {
|
||||
title?: string;
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: int32 */
|
||||
@@ -1213,6 +1218,7 @@ export interface components {
|
||||
deathYear?: number;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
personType?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatDocumentStatus } from './documentStatusLabel';
|
||||
|
||||
type Person = { firstName: string; lastName: string };
|
||||
type Person = { firstName?: string | null; lastName: string; displayName: string };
|
||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||
type DocForMeta = {
|
||||
sender?: Person | null;
|
||||
@@ -18,7 +18,13 @@ function djb2(str: string): number {
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function getInitials(person: Person): string {
|
||||
if (person.firstName) return `${person.firstName[0]}${person.lastName[0]}`.toUpperCase();
|
||||
return person.lastName.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function abbreviateName(person: Person): string {
|
||||
if (!person.firstName) return person.lastName;
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
@@ -26,6 +32,7 @@ export function abbreviateName(person: Person): string {
|
||||
}
|
||||
|
||||
function abbreviateCompact(person: Person): string {
|
||||
if (!person.firstName) return person.lastName;
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
|
||||
@@ -93,8 +93,8 @@ export async function load({ url, fetch }) {
|
||||
incompleteDocs,
|
||||
recentDocs,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||
senderName: senderObj?.displayName ?? '',
|
||||
receiverName: receiverObj?.displayName ?? ''
|
||||
},
|
||||
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ },
|
||||
error: null as string | null
|
||||
|
||||
@@ -16,8 +16,8 @@ let {
|
||||
originalFilename: string;
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
sender?: { firstName: string; lastName: string } | null;
|
||||
receivers?: { firstName: string; lastName: string }[];
|
||||
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
|
||||
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
}[];
|
||||
canWrite: boolean;
|
||||
@@ -102,7 +102,7 @@ let {
|
||||
>{m.docs_list_from()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
<span class="text-ink">{doc.sender.firstName} {doc.sender.lastName}</span>
|
||||
<span class="text-ink">{doc.sender.displayName}</span>
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
@@ -114,7 +114,7 @@ let {
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<span class="text-ink">
|
||||
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
|
||||
{doc.receivers.map((p) => p.displayName).join(', ')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
|
||||
@@ -52,8 +52,8 @@ export async function load({ url, fetch, locals }) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
const p = result.data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
||||
const p = result.data as { displayName: string } | undefined;
|
||||
if (p) senderName = p.displayName;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -65,8 +65,8 @@ export async function load({ url, fetch, locals }) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
const p = result.data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
||||
const p = result.data as { displayName: string } | undefined;
|
||||
if (p) receiverName = p.displayName;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,13 @@ interface Props {
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
status: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||
sender?: {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
receivers?: { id: string; firstName?: string | null; lastName: string; displayName: string }[];
|
||||
}[];
|
||||
senderId: string;
|
||||
receiverId?: string;
|
||||
@@ -67,9 +72,9 @@ function statusDotClass(status: string): string {
|
||||
function otherPartyName(doc: (typeof documents)[number]): string {
|
||||
if (doc.sender?.id === senderId) {
|
||||
const r = doc.receivers?.[0];
|
||||
return r ? `${r.firstName} ${r.lastName}` : m.conv_no_party();
|
||||
return r ? r.displayName : m.conv_no_party();
|
||||
}
|
||||
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : m.conv_no_party();
|
||||
return doc.sender ? doc.sender.displayName : m.conv_no_party();
|
||||
}
|
||||
|
||||
const newDocUrl = $derived(
|
||||
|
||||
@@ -4,8 +4,9 @@ import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
interface Correspondent {
|
||||
id: string;
|
||||
firstName: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -41,7 +42,9 @@ function handleKeydown(event: KeyboardEvent, container: HTMLElement) {
|
||||
}
|
||||
|
||||
function getInitials(person: Correspondent): string {
|
||||
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
|
||||
if (person.firstName)
|
||||
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
|
||||
return person.lastName.substring(0, 2).toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -78,7 +81,7 @@ function getInitials(person: Correspondent): string {
|
||||
{getInitials(person)}
|
||||
</span>
|
||||
<!-- Svelte auto-escapes — do not use {@html} here. -->
|
||||
{person.lastName}, {person.firstName}
|
||||
{person.displayName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -54,7 +54,15 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
|
||||
const docs = [{ id: 'd1', title: 'Testbrief' }];
|
||||
const GET = mockApi([
|
||||
{ ok: true, data: docs },
|
||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
firstName: 'Hans',
|
||||
lastName: 'Müller',
|
||||
displayName: 'Hans Müller',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const result = await load({
|
||||
@@ -76,8 +84,24 @@ describe('korrespondenz load — senderId and receiverId set', () => {
|
||||
it('calls conversation, sender person, and receiver person endpoints', async () => {
|
||||
const GET = mockApi([
|
||||
{ ok: true, data: [] },
|
||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } },
|
||||
{ ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } }
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
firstName: 'Hans',
|
||||
lastName: 'Müller',
|
||||
displayName: 'Hans Müller',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt',
|
||||
displayName: 'Anna Schmidt',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const result = await load({
|
||||
@@ -98,7 +122,15 @@ describe('korrespondenz load — canWrite', () => {
|
||||
it('derives canWrite true from WRITE_ALL permission', async () => {
|
||||
mockApi([
|
||||
{ ok: true, data: [] },
|
||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
firstName: 'Hans',
|
||||
lastName: 'Müller',
|
||||
displayName: 'Hans Müller',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const result = await load({
|
||||
@@ -113,7 +145,15 @@ describe('korrespondenz load — canWrite', () => {
|
||||
it('derives canWrite false when user lacks WRITE_ALL', async () => {
|
||||
mockApi([
|
||||
{ ok: true, data: [] },
|
||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
firstName: 'Hans',
|
||||
lastName: 'Müller',
|
||||
displayName: 'Hans Müller',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const result = await load({
|
||||
@@ -132,7 +172,15 @@ describe('korrespondenz load — backend error', () => {
|
||||
it('throws when the conversation endpoint returns non-ok', async () => {
|
||||
mockApi([
|
||||
{ ok: false, status: 500 },
|
||||
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
firstName: 'Hans',
|
||||
lastName: 'Müller',
|
||||
displayName: 'Hans Müller',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -39,8 +39,8 @@ export async function load({ url, fetch }) {
|
||||
if (senderId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
||||
const p = data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
||||
const p = data as { displayName: string } | undefined;
|
||||
if (p) senderName = p.displayName;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -48,8 +48,8 @@ export async function load({ url, fetch }) {
|
||||
if (receiverId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
||||
const p = data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
||||
const p = data as { displayName: string } | undefined;
|
||||
if (p) receiverName = p.displayName;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ let {
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
status: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
sender?: {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
}[];
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
@@ -106,7 +111,7 @@ const enrichedDocuments = $derived(
|
||||
: 'border-line bg-surface text-ink'}"
|
||||
>
|
||||
{#if doc.sender}
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
{doc.sender.firstName ? doc.sender.firstName[0] : doc.sender.lastName[0]}{doc.sender.lastName[0]}
|
||||
{:else}
|
||||
?
|
||||
{/if}
|
||||
|
||||
@@ -54,7 +54,7 @@ let selectedReceivers = $state(doc.receivers ?? []);
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
initialDateIso={doc.documentDate ?? ''}
|
||||
initialLocation={doc.location ?? ''}
|
||||
initialSenderName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
|
||||
initialSenderName={doc.sender ? doc.sender.displayName : ''}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
|
||||
@@ -24,14 +24,19 @@ export async function load({
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
let initialSenderName = '';
|
||||
let initialReceivers: { id: string; firstName: string; lastName: string }[] = [];
|
||||
let initialReceivers: {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
}[] = [];
|
||||
|
||||
const requests: Promise<void>[] = [];
|
||||
|
||||
if (senderId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
||||
if (data) initialSenderName = `${data.firstName} ${data.lastName}`;
|
||||
if (data) initialSenderName = data.displayName;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -40,7 +45,14 @@ export async function load({
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
||||
if (data)
|
||||
initialReceivers = [{ id: data.id!, firstName: data.firstName, lastName: data.lastName }];
|
||||
initialReceivers = [
|
||||
{
|
||||
id: data.id!,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
displayName: data.displayName
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ let { data, form } = $props();
|
||||
|
||||
let tags: string[] = $state([]);
|
||||
let senderId = $state(untrack(() => data.initialSenderId));
|
||||
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
|
||||
untrack(() => data.initialReceivers)
|
||||
);
|
||||
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
||||
$state(untrack(() => data.initialReceivers));
|
||||
|
||||
let parsedSuggestion = $state<FilenameParseResult>({});
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ describe('New document page – receiver prefill', () => {
|
||||
it('shows a receiver chip when initialReceivers has a person', async () => {
|
||||
const data = {
|
||||
...baseData,
|
||||
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||
initialReceivers: [
|
||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||
]
|
||||
};
|
||||
render(Page, { data, form: null });
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
@@ -67,7 +69,9 @@ describe('New document page – receiver prefill', () => {
|
||||
it('renders a hidden receiverIds input for the prefilled receiver', async () => {
|
||||
const data = {
|
||||
...baseData,
|
||||
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||
initialReceivers: [
|
||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||
]
|
||||
};
|
||||
render(Page, { data, form: null });
|
||||
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
||||
|
||||
@@ -120,7 +120,7 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
||||
initialDateIso={doc.documentDate ?? ''}
|
||||
initialLocation={doc.location ?? ''}
|
||||
initialSenderName={doc.sender
|
||||
? `${doc.sender.firstName} ${doc.sender.lastName}`
|
||||
? doc.sender.displayName
|
||||
: ''}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />
|
||||
|
||||
@@ -106,6 +106,19 @@
|
||||
--c-pdf-bg: #ebebeb;
|
||||
--c-pdf-ctrl: #d8d8d8;
|
||||
--c-pdf-text: #333333;
|
||||
|
||||
/* PersonType badge — institution (navy-tinted blue) */
|
||||
--c-badge-institution-bg: #e8eff7;
|
||||
--c-badge-institution-text: #1a4971;
|
||||
--c-badge-institution-border: #c4d5e8;
|
||||
/* PersonType badge — group (muted purple) */
|
||||
--c-badge-group-bg: #f0e8f5;
|
||||
--c-badge-group-text: #5a2d6f;
|
||||
--c-badge-group-border: #d8c5e3;
|
||||
/* PersonType badge — unknown (amber warning) */
|
||||
--c-badge-unknown-bg: #fdf4e3;
|
||||
--c-badge-unknown-text: #7a5a0a;
|
||||
--c-badge-unknown-border: #f0ddb3;
|
||||
}
|
||||
|
||||
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||
@@ -148,6 +161,16 @@
|
||||
--c-pdf-bg: #010e1e;
|
||||
--c-pdf-ctrl: #011526;
|
||||
--c-pdf-text: #f0efe9;
|
||||
|
||||
--c-badge-institution-bg: rgba(30, 80, 140, 0.25);
|
||||
--c-badge-institution-text: #8bb8e0;
|
||||
--c-badge-institution-border: rgba(30, 80, 140, 0.4);
|
||||
--c-badge-group-bg: rgba(90, 45, 111, 0.25);
|
||||
--c-badge-group-text: #c9a0dc;
|
||||
--c-badge-group-border: rgba(90, 45, 111, 0.4);
|
||||
--c-badge-unknown-bg: rgba(122, 90, 10, 0.25);
|
||||
--c-badge-unknown-text: #e0c060;
|
||||
--c-badge-unknown-border: rgba(122, 90, 10, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +209,16 @@
|
||||
--c-pdf-bg: #010e1e;
|
||||
--c-pdf-ctrl: #011526;
|
||||
--c-pdf-text: #f0efe9;
|
||||
|
||||
--c-badge-institution-bg: rgba(30, 80, 140, 0.25);
|
||||
--c-badge-institution-text: #8bb8e0;
|
||||
--c-badge-institution-border: rgba(30, 80, 140, 0.4);
|
||||
--c-badge-group-bg: rgba(90, 45, 111, 0.25);
|
||||
--c-badge-group-text: #c9a0dc;
|
||||
--c-badge-group-border: rgba(90, 45, 111, 0.4);
|
||||
--c-badge-unknown-bg: rgba(122, 90, 10, 0.25);
|
||||
--c-badge-unknown-text: #e0c060;
|
||||
--c-badge-unknown-border: rgba(122, 90, 10, 0.4);
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
|
||||
@@ -39,8 +39,22 @@ const makeDoc = (overrides = {}) => ({
|
||||
status: 'UPLOADED' as const,
|
||||
documentDate: '2024-03-15',
|
||||
location: 'Berlin',
|
||||
sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }],
|
||||
sender: {
|
||||
id: 'p1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON' as const
|
||||
},
|
||||
receivers: [
|
||||
{
|
||||
id: 'p2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON' as const
|
||||
}
|
||||
],
|
||||
tags: [{ id: 't1', name: 'Familie' }],
|
||||
filePath: '/files/testbrief.pdf',
|
||||
createdAt: '2024-03-15T10:00:00Z',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import PersonTypeBadge from '$lib/components/PersonTypeBadge.svelte';
|
||||
import PersonsStatsBar from './PersonsStatsBar.svelte';
|
||||
import PersonsEmptyState from './PersonsEmptyState.svelte';
|
||||
|
||||
@@ -99,15 +100,48 @@ function handleSearch() {
|
||||
<div
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-primary font-serif text-base font-bold text-primary-fg transition-colors"
|
||||
>
|
||||
{person.firstName?.[0]}{person.lastName?.[0]}
|
||||
{#if person.personType && person.personType !== 'PERSON'}
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if person.personType === 'INSTITUTION'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
|
||||
/>
|
||||
{:else if person.personType === 'GROUP'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{:else}
|
||||
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<p class="font-serif text-sm font-bold text-ink group-hover:underline">
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
{person.displayName}
|
||||
</p>
|
||||
|
||||
{#if person.personType && person.personType !== 'PERSON'}
|
||||
<PersonTypeBadge personType={person.personType} />
|
||||
{/if}
|
||||
|
||||
<!-- Alias -->
|
||||
{#if person.alias}
|
||||
<p class="font-sans text-xs text-ink-2 italic">„{person.alias}"</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ const coCorrespondents = $derived.by(() => {
|
||||
else
|
||||
freq.set(key, {
|
||||
id: receiver.id,
|
||||
name: `${receiver.firstName} ${receiver.lastName}`,
|
||||
name: receiver.displayName,
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
@@ -37,7 +37,7 @@ const coCorrespondents = $derived.by(() => {
|
||||
else
|
||||
freq.set(key, {
|
||||
id: doc.sender.id,
|
||||
name: `${doc.sender.firstName} ${doc.sender.lastName}`,
|
||||
name: doc.sender.displayName,
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
type: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
personFirstName: string;
|
||||
personFirstName?: string | null;
|
||||
}
|
||||
|
||||
let { aliases, personFirstName }: Props = $props();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import PersonTypeBadge from '$lib/components/PersonTypeBadge.svelte';
|
||||
|
||||
let {
|
||||
person,
|
||||
@@ -8,8 +9,10 @@ let {
|
||||
}: {
|
||||
person: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
personType?: string | null;
|
||||
alias?: string | null;
|
||||
birthYear?: number | null;
|
||||
deathYear?: number | null;
|
||||
@@ -29,16 +32,51 @@ let {
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary font-serif text-xl font-bold text-primary-fg"
|
||||
>
|
||||
{person.firstName[0]}{person.lastName[0]}
|
||||
{#if person.personType && person.personType !== 'PERSON'}
|
||||
<svg
|
||||
class="h-7 w-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if person.personType === 'INSTITUTION'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
|
||||
/>
|
||||
{:else if person.personType === 'GROUP'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{:else}
|
||||
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name — centered, serif -->
|
||||
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
{person.displayName}
|
||||
</h1>
|
||||
|
||||
{#if person.personType && person.personType !== 'PERSON'}
|
||||
<div class="mb-1 flex justify-center">
|
||||
<PersonTypeBadge personType={person.personType} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Alias — centered, italic -->
|
||||
{#if person.alias}
|
||||
<p class="mb-1 text-center font-sans text-sm text-ink-2 italic">„{person.alias}"</p>
|
||||
|
||||
@@ -7,7 +7,7 @@ let {
|
||||
person,
|
||||
form
|
||||
}: {
|
||||
person: { firstName: string; lastName: string };
|
||||
person: { displayName: string };
|
||||
form?: { mergeError?: string } | null;
|
||||
} = $props();
|
||||
|
||||
@@ -74,7 +74,7 @@ let showMergeConfirm = $state(false);
|
||||
|
||||
{#if showMergeConfirm}
|
||||
<p class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong>
|
||||
{m.person_merge_warning()} <strong>{person.displayName}</strong>
|
||||
{m.person_merge_will_be_deleted()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -30,8 +30,7 @@ const person = $derived(data.person);
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
{person.displayName}
|
||||
</a>
|
||||
<h1 class="font-serif text-3xl text-ink">{m.person_edit_heading()}</h1>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ let {
|
||||
person,
|
||||
form
|
||||
}: {
|
||||
person: { id: string; firstName: string; lastName: string };
|
||||
person: { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
form?: { mergeError?: string } | null;
|
||||
} = $props();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ let {
|
||||
person
|
||||
}: {
|
||||
person: {
|
||||
firstName: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
alias?: string | null;
|
||||
birthYear?: number | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ const makePerson = (overrides = {}) => ({
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
documentCount: 0,
|
||||
...overrides
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user