feat: Persons section redesign — Concept A (Enriched Directory) #159

Merged
marcel merged 19 commits from feat/persons-redesign-concept-a into main 2026-03-29 21:36:30 +02:00
41 changed files with 1490 additions and 540 deletions

View File

@@ -4,16 +4,23 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@@ -25,7 +32,7 @@ public class PersonController {
private final DocumentService documentService;
@GetMapping
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
return ResponseEntity.ok(personService.findAll(q));
}
@@ -52,17 +59,20 @@ public class PersonController {
}
@PostMapping
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
String firstName = body.get("firstName");
String lastName = body.get("lastName");
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
return ResponseEntity.ok(personService.createPerson(firstName.trim(), lastName.trim(), body.get("alias")));
dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
return ResponseEntity.ok(personService.createPerson(dto));
}
@PutMapping("/{id}")
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) {
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
@@ -74,6 +84,7 @@ public class PersonController {
@PostMapping("/{id}/merge")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void mergePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
String targetIdStr = body.get("targetPersonId");
if (targetIdStr == null || targetIdStr.isBlank()) {

View File

@@ -0,0 +1,25 @@
package org.raddatz.familienarchiv.controller;
import org.raddatz.familienarchiv.dto.StatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final PersonRepository personRepository;
private final DocumentRepository documentRepository;
@GetMapping
public ResponseEntity<StatsDTO> getStats() {
return ResponseEntity.ok(new StatsDTO(personRepository.count(), documentRepository.count()));
}
}

View File

@@ -0,0 +1,19 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
/**
* Projection returned by the /api/persons list endpoint.
* Includes document count to avoid N+1 queries in the UI.
* Uses interface projection for compatibility with native queries.
*/
public interface PersonSummaryDTO {
UUID getId();
String getFirstName();
String getLastName();
String getAlias();
Integer getBirthYear();
Integer getDeathYear();
String getNotes();
long getDocumentCount();
}

View File

@@ -1,12 +1,17 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class PersonUpdateDTO {
@Size(max = 100)
private String firstName;
@Size(max = 100)
private String lastName;
@Size(max = 200)
private String alias;
@Size(max = 5000)
private String notes;
private Integer birthYear;
private Integer deathYear;

View File

@@ -0,0 +1,7 @@
package org.raddatz.familienarchiv.dto;
/**
* Aggregate counts for the dashboard/persons stats bar.
*/
public record StatsDTO(long totalPersons, long totalDocuments) {
}

View File

@@ -8,6 +8,10 @@ package org.raddatz.familienarchiv.exception;
*/
public enum ErrorCode {
// --- Persons ---
/** A person with the given ID does not exist. 404 */
PERSON_NOT_FOUND,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,

View File

@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.model.Person;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
@@ -31,6 +32,33 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Exact first+last name match, used for filename-based sender lookup
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
// --- PersonSummaryDTO with document count ---
@Query(value = """
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
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
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)
List<PersonSummaryDTO> findAllWithDocumentCount();
@Query(value = """
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
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
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,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)
List<PersonSummaryDTO> searchWithDocumentCount(@Param("query") String query);
// --- Correspondent queries ---
@Query(value = """

View File

@@ -4,7 +4,10 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.HttpStatus;
@@ -20,16 +23,16 @@ public class PersonService {
private final PersonRepository personRepository;
public List<Person> findAll(String q) {
public List<PersonSummaryDTO> findAll(String q) {
if (q != null && !q.isBlank()) {
return personRepository.searchByName(q);
return personRepository.searchWithDocumentCount(q);
}
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
return personRepository.findAllWithDocumentCount();
}
public Person getById(UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
}
public List<Person> findCorrespondents(UUID personId, String q) {
@@ -71,12 +74,36 @@ public class PersonService {
}
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) {
public Person createPerson(PersonUpdateDTO dto) {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthYear(dto.getBirthYear())
.deathYear(dto.getDeathYear())
.build();
return personRepository.save(person);
}
private void validateYears(Integer birthYear, Integer deathYear) {
if (birthYear != null && birthYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
}
if (deathYear != null && deathYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
}
if (birthYear != null && deathYear != null && birthYear > deathYear) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
}
}
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
@@ -92,9 +119,9 @@ public class PersonService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Quelle und Ziel dürfen nicht identisch sein");
}
personRepository.findById(sourceId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Quell-Person nicht gefunden"));
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Source person not found: " + sourceId));
personRepository.findById(targetId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ziel-Person nicht gefunden"));
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Target person not found: " + targetId));
// Reassign sender references
personRepository.reassignSender(sourceId, targetId);

View File

@@ -21,6 +21,8 @@ import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@@ -57,14 +59,27 @@ class PersonControllerTest {
@Test
@WithMockUser
void getPersons_delegatesQueryParam_toService() throws Exception {
Person person = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.findAll("Hans")).thenReturn(List.of(person));
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
when(personService.findAll("Hans")).thenReturn(List.of(dto));
mockMvc.perform(get("/api/persons").param("q", "Hans"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Hans"));
}
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
return new PersonSummaryDTO() {
public java.util.UUID getId() { return UUID.randomUUID(); }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public String getAlias() { return null; }
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public String getNotes() { return null; }
public long getDocumentCount() { return 0; }
};
}
// ─── GET /api/persons/{id} ────────────────────────────────────────────────
@Test
@@ -162,7 +177,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
@@ -171,7 +186,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
@@ -180,7 +195,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
@@ -189,7 +204,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
@@ -198,10 +213,10 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_whenValid() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.createPerson(eq("Hans"), eq("Müller"), any())).thenReturn(saved);
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
@@ -221,7 +236,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
@@ -230,7 +245,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
@@ -239,7 +254,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns200_whenValid() throws Exception {
UUID id = UUID.randomUUID();
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
@@ -263,7 +278,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
@@ -272,7 +287,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
@@ -281,7 +296,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns204_whenValid() throws Exception {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
@@ -304,4 +319,78 @@ class PersonControllerTest {
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.andExpect(status().isBadRequest());
}
// ─── Phase 2.2: POST /api/persons with full PersonUpdateDTO ───────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_withAllSixFields() throws Exception {
UUID id = UUID.randomUUID();
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"notes\":\"Some notes\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
.andExpect(jsonPath("$.birthYear").value(1901));
}
// ─── Phase 1.2: @Size constraints ─────────────────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
String oversizedNotes = "x".repeat(5001);
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
String oversizedFirstName = "x".repeat(101);
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
// ─── Phase 1.1: @RequirePermission(WRITE_ALL) on write endpoints ──────────
@Test
@WithMockUser(authorities = "READ_ALL")
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden());
}
}

View File

@@ -0,0 +1,61 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(StatsController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class StatsControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean PersonRepository personRepository;
@MockitoBean DocumentRepository documentRepository;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
void getStats_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/stats"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getStats_returns200_withCorrectCounts() throws Exception {
when(personRepository.count()).thenReturn(4L);
when(documentRepository.count()).thenReturn(12L);
mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalPersons").value(4))
.andExpect(jsonPath("$.totalDocuments").value(12));
}
@Test
@WithMockUser
void getStats_returns200_withZeroCounts() throws Exception {
when(personRepository.count()).thenReturn(0L);
when(documentRepository.count()).thenReturn(0L);
mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalPersons").value(0))
.andExpect(jsonPath("$.totalDocuments").value(0));
}
}

View File

@@ -11,6 +11,8 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.List;
@@ -288,6 +290,76 @@ class PersonRepositoryTest {
assertThat(targetCount).isEqualTo(1); // no duplicate
}
// ─── Phase 3.2: findAllWithDocumentCount ──────────────────────────────────
@Test
void findAllWithDocumentCount_includesDocumentCountAsSenderAndReceiver() {
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
// Walter sends 2 docs to Anna (Anna receives 2)
documentRepository.save(Document.builder()
.title("Brief 1").originalFilename("b1.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(anna)).build());
documentRepository.save(Document.builder()
.title("Brief 2").originalFilename("b2.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(anna)).build());
// Anna also sends 1 doc to Walter
documentRepository.save(Document.builder()
.title("Brief 3").originalFilename("b3.pdf")
.status(DocumentStatus.UPLOADED)
.sender(anna).receivers(Set.of(walter)).build());
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
PersonSummaryDTO walterSummary = result.stream()
.filter(p -> p.getId().equals(walter.getId())).findFirst().orElseThrow();
PersonSummaryDTO annaSummary = result.stream()
.filter(p -> p.getId().equals(anna.getId())).findFirst().orElseThrow();
assertThat(walterSummary.getDocumentCount()).isEqualTo(3); // sent 2, received 1
assertThat(annaSummary.getDocumentCount()).isEqualTo(3); // sent 1, received 2
}
@Test
void findAllWithDocumentCount_returnsZero_whenPersonHasNoDocuments() {
Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build());
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
PersonSummaryDTO soloSummary = result.stream()
.filter(p -> p.getId().equals(solo.getId())).findFirst().orElseThrow();
assertThat(soloSummary.getDocumentCount()).isEqualTo(0);
}
@Test
void searchWithDocumentCount_filtersAndIncludesCount() {
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(hans).receivers(Set.of(anna)).build());
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Hans");
assertThat(result).hasSize(1);
assertThat(result.get(0).getFirstName()).isEqualTo("Hans");
assertThat(result.get(0).getDocumentCount()).isEqualTo(1);
}
@Test
void searchWithDocumentCount_isCaseInsensitive() {
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("hans");
assertThat(result).hasSize(1);
}
// ─── deleteReceiverReferences ─────────────────────────────────────────────
@Test

View File

@@ -5,7 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.web.server.ResponseStatusException;
@@ -33,8 +35,8 @@ class PersonServiceTest {
when(personRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.getById(id))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(404);
}
@@ -51,32 +53,32 @@ class PersonServiceTest {
@Test
void findAll_returnsAll_whenQueryIsNull() {
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build());
when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
List<PersonSummaryDTO> expected = List.of();
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
assertThat(personService.findAll(null)).isEqualTo(expected);
verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
verify(personRepository, never()).searchByName(any());
verify(personRepository).findAllWithDocumentCount();
verify(personRepository, never()).searchWithDocumentCount(any());
}
@Test
void findAll_returnsAll_whenQueryIsBlank() {
List<Person> expected = List.of();
when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
List<PersonSummaryDTO> expected = List.of();
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
assertThat(personService.findAll(" ")).isEqualTo(expected);
verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
verify(personRepository, never()).searchByName(any());
verify(personRepository).findAllWithDocumentCount();
verify(personRepository, never()).searchWithDocumentCount(any());
}
@Test
void findAll_searchesByName_whenQueryIsNonBlank() {
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Müller").build());
when(personRepository.searchByName("Anna")).thenReturn(expected);
List<PersonSummaryDTO> expected = List.of();
when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected);
assertThat(personService.findAll("Anna")).isEqualTo(expected);
verify(personRepository).searchByName("Anna");
verify(personRepository, never()).findAllByOrderByLastNameAscFirstNameAsc();
verify(personRepository).searchWithDocumentCount("Anna");
verify(personRepository, never()).findAllWithDocumentCount();
}
// ─── createPerson ─────────────────────────────────────────────────────────
@@ -109,6 +111,37 @@ class PersonServiceTest {
assertThat(result.getAlias()).isEqualTo("Hans Müller");
}
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
@Test
void createPerson_dto_persistsAllSixFields() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes");
Person result = personService.createPerson(dto);
assertThat(result.getFirstName()).isEqualTo("Maria");
assertThat(result.getLastName()).isEqualTo("Raddatz");
assertThat(result.getAlias()).isEqualTo("Oma Maria");
assertThat(result.getBirthYear()).isEqualTo(1901);
assertThat(result.getDeathYear()).isEqualTo(1975);
assertThat(result.getNotes()).isEqualTo("Some notes");
}
@Test
void createPerson_dto_yearValidationFires_whenBirthYearNegative() {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1);
assertThatThrownBy(() -> personService.createPerson(dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
// ─── updatePerson (alias) ─────────────────────────────────────────────────
@Test
@@ -267,6 +300,56 @@ class PersonServiceTest {
assertThat(result.getDeathYear()).isEqualTo(1900);
}
// ─── Phase 1.3: Year range bounds (> 0) ──────────────────────────────────
@Test
void updatePerson_throwsBadRequest_whenBirthYearIsZero() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsZero() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
// ─── findCorrespondents ──────────────────────────────────────────────────
@Test
@@ -321,8 +404,8 @@ class PersonServiceTest {
when(personRepository.findById(sourceId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(404);
}
@@ -335,8 +418,8 @@ class PersonServiceTest {
when(personRepository.findById(targetId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(404);
}

View File

@@ -8,6 +8,10 @@ bun.lockb
# Miscellaneous
/static/
# Build artifacts
/.svelte-kit/
/.svelte-kit-backup/
# Generated files
/.svelte-kit-backup/
/src/lib/generated/

View File

@@ -120,6 +120,7 @@
"person_role_sender": "Gesendet",
"person_role_receiver": "Empfangen",
"person_co_correspondents_heading": "Häufige Korrespondenten",
"person_correspondents_hint": "klicken für Konversation",
"person_show_more": "+ {count} weitere anzeigen",
"conv_heading": "Konversationen",
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
@@ -321,6 +322,30 @@
"dashboard_recent_heading": "Zuletzt aktiv",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter",
"doc_status_uploaded": "Hochgeladen",
"doc_status_transcribed": "Transkribiert",
"doc_status_reviewed": "Geprüft",
"doc_status_archived": "Archiviert",
"doc_status_unknown": "Unbekannt",
"persons_stats_persons_one": "1 Person",
"persons_stats_persons_many": "{count} Personen",
"persons_stats_documents_one": "1 Dokument",
"persons_stats_documents_many": "{count} Dokumente",
"persons_stats_label_persons_one": "Person",
"persons_stats_label_persons_many": "Personen",
"persons_stats_label_documents_one": "Dokument",
"persons_stats_label_documents_many": "Dokumente",
"person_card_doc_count_one": "1 Dok.",
"person_card_doc_count_many": "{count} Dok.",
"error_person_not_found": "Die Person wurde nicht gefunden.",
"person_btn_edit": "Bearbeiten",
"person_discard_changes": "Änderungen verwerfen",
"person_danger_zone_heading": "Gefahrenzone",
"persons_new_birth_year": "Geburtsjahr",
"persons_new_death_year": "Todesjahr",
"persons_new_notes": "Notizen",
"person_save_changes": "Änderungen speichern",
"notification_view_all": "Alle anzeigen →",
"notification_history_heading": "Benachrichtigungen",
"notification_history_view_link": "Benachrichtigungsverlauf ansehen →",

View File

@@ -120,6 +120,7 @@
"person_role_sender": "Sent",
"person_role_receiver": "Received",
"person_co_correspondents_heading": "Frequent correspondents",
"person_correspondents_hint": "click to view conversation",
"person_show_more": "+ {count} more",
"conv_heading": "Conversations",
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
@@ -321,6 +322,30 @@
"dashboard_recent_heading": "Recent Activity",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder",
"doc_status_uploaded": "Uploaded",
"doc_status_transcribed": "Transcribed",
"doc_status_reviewed": "Reviewed",
"doc_status_archived": "Archived",
"doc_status_unknown": "Unknown",
"persons_stats_persons_one": "1 person",
"persons_stats_persons_many": "{count} persons",
"persons_stats_documents_one": "1 document",
"persons_stats_documents_many": "{count} documents",
"persons_stats_label_persons_one": "Person",
"persons_stats_label_persons_many": "Persons",
"persons_stats_label_documents_one": "Document",
"persons_stats_label_documents_many": "Documents",
"person_card_doc_count_one": "1 doc",
"person_card_doc_count_many": "{count} docs",
"error_person_not_found": "Person not found.",
"person_btn_edit": "Edit",
"person_discard_changes": "Discard changes",
"person_danger_zone_heading": "Danger zone",
"persons_new_birth_year": "Birth year",
"persons_new_death_year": "Death year",
"persons_new_notes": "Notes",
"person_save_changes": "Save changes",
"notification_view_all": "View all →",
"notification_history_heading": "Notifications",
"notification_history_view_link": "View notification history →",

View File

@@ -120,6 +120,7 @@
"person_role_sender": "Enviado",
"person_role_receiver": "Recibido",
"person_co_correspondents_heading": "Corresponsales frecuentes",
"person_correspondents_hint": "clic para ver conversación",
"person_show_more": "+ {count} más",
"conv_heading": "Conversaciones",
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
@@ -321,6 +322,30 @@
"dashboard_recent_heading": "Actividad reciente",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador",
"doc_status_uploaded": "Cargado",
"doc_status_transcribed": "Transcrito",
"doc_status_reviewed": "Revisado",
"doc_status_archived": "Archivado",
"doc_status_unknown": "Desconocido",
"persons_stats_persons_one": "1 persona",
"persons_stats_persons_many": "{count} personas",
"persons_stats_documents_one": "1 documento",
"persons_stats_documents_many": "{count} documentos",
"persons_stats_label_persons_one": "Persona",
"persons_stats_label_persons_many": "Personas",
"persons_stats_label_documents_one": "Documento",
"persons_stats_label_documents_many": "Documentos",
"person_card_doc_count_one": "1 doc.",
"person_card_doc_count_many": "{count} docs.",
"error_person_not_found": "Persona no encontrada.",
"person_btn_edit": "Editar",
"person_discard_changes": "Descartar cambios",
"person_danger_zone_heading": "Zona de peligro",
"persons_new_birth_year": "Año de nacimiento",
"persons_new_death_year": "Año de fallecimiento",
"persons_new_notes": "Notas",
"person_save_changes": "Guardar cambios",
"notification_view_all": "Ver todas →",
"notification_history_heading": "Notificaciones",
"notification_history_view_link": "Ver historial de notificaciones →",

View File

@@ -5,6 +5,7 @@ import * as m from '$lib/paraglide/messages.js';
* Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
*/
export type ErrorCode =
| 'PERSON_NOT_FOUND'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
@@ -47,6 +48,8 @@ export async function parseBackendError(res: Response): Promise<BackendError | n
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
export function getErrorMessage(code: ErrorCode | string | undefined): string {
switch (code) {
case 'PERSON_NOT_FOUND':
return m.error_person_not_found();
case 'DOCUMENT_NOT_FOUND':
return m.error_document_not_found();
case 'DOCUMENT_NO_FILE':

View File

@@ -468,6 +468,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getStats"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/received-documents": {
parameters: {
query?: never;
@@ -708,22 +724,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/auth/reset-token-for-test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getResetTokenForTest"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/import-status": {
parameters: {
query?: never;
@@ -1004,12 +1004,34 @@ export interface components {
actorName?: string;
documentTitle?: string;
};
StatsDTO: {
/** Format: int64 */
totalPersons?: number;
/** Format: int64 */
totalDocuments?: number;
};
PersonSummaryDTO: {
/** Format: uuid */
id?: string;
firstName?: string;
lastName?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
alias?: string;
notes?: string;
/** Format: int64 */
documentCount?: number;
};
PageNotificationDTO: {
/** Format: int64 */
totalElements?: number;
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -1018,8 +1040,6 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -1480,7 +1500,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"][];
"*/*": components["schemas"]["PersonSummaryDTO"][];
};
};
};
@@ -1494,9 +1514,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": {
[key: string]: string;
};
"application/json": components["schemas"]["PersonUpdateDTO"];
};
};
responses: {
@@ -2112,6 +2130,26 @@ export interface operations {
};
};
};
getStats: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["StatsDTO"];
};
};
};
};
getPersonReceivedDocuments: {
parameters: {
query?: never;
@@ -2185,9 +2223,7 @@ export interface operations {
query?: {
page?: number;
size?: number;
/** @description Filter by notification type */
type?: "REPLY" | "MENTION";
/** @description Filter by read status */
read?: boolean;
};
header?: never;
@@ -2460,28 +2496,6 @@ export interface operations {
};
};
};
getResetTokenForTest: {
parameters: {
query: {
email: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
importStatus: {
parameters: {
query?: never;

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { formatDocumentStatus } from './documentStatusLabel';
describe('formatDocumentStatus', () => {
it('maps PLACEHOLDER to correct label', () => {
expect(formatDocumentStatus('PLACEHOLDER')).toBe('Platzhalter');
});
it('maps UPLOADED to correct label', () => {
expect(formatDocumentStatus('UPLOADED')).toBe('Hochgeladen');
});
it('maps TRANSCRIBED to correct label', () => {
expect(formatDocumentStatus('TRANSCRIBED')).toBe('Transkribiert');
});
it('maps REVIEWED to correct label', () => {
expect(formatDocumentStatus('REVIEWED')).toBe('Geprüft');
});
it('maps ARCHIVED to correct label', () => {
expect(formatDocumentStatus('ARCHIVED')).toBe('Archiviert');
});
it('returns fallback for unknown status', () => {
expect(formatDocumentStatus('SOMETHING_NEW')).toBe('Unbekannt');
});
});

View File

@@ -0,0 +1,22 @@
import { m } from '$lib/paraglide/messages.js';
/**
* Maps a document status string to a localised human-readable label.
* Falls back to "Unknown" for unrecognised values.
*/
export function formatDocumentStatus(status: string): string {
switch (status) {
case 'PLACEHOLDER':
return m.doc_status_placeholder();
case 'UPLOADED':
return m.doc_status_uploaded();
case 'TRANSCRIBED':
return m.doc_status_transcribed();
case 'REVIEWED':
return m.doc_status_reviewed();
case 'ARCHIVED':
return m.doc_status_archived();
default:
return m.doc_status_unknown();
}
}

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { formatLifeDateRange } from './personLifeDates';
describe('formatLifeDateRange', () => {
it('returns both dates when birth and death year are given', () => {
expect(formatLifeDateRange(1882, 1944)).toBe('* 1882 † 1944');
});
it('returns only birth year when only birthYear is given', () => {
expect(formatLifeDateRange(1882, undefined)).toBe('* 1882');
});
it('returns only death year when only deathYear is given', () => {
expect(formatLifeDateRange(undefined, 1944)).toBe('† 1944');
});
it('returns empty string when neither year is given', () => {
expect(formatLifeDateRange(undefined, undefined)).toBe('');
});
it('returns empty string when both are null', () => {
expect(formatLifeDateRange(null, null)).toBe('');
});
});

View File

@@ -0,0 +1,20 @@
/**
* Formats the life date range for a person.
* Examples:
* * 1882 † 1944 (both)
* * 1882 (birth only)
* † 1944 (death only)
* "" (neither)
*/
export function formatLifeDateRange(birthYear?: number | null, deathYear?: number | null): string {
if (birthYear && deathYear) {
return `* ${birthYear} ${deathYear}`;
}
if (birthYear) {
return `* ${birthYear}`;
}
if (deathYear) {
return `${deathYear}`;
}
return '';
}

View File

@@ -45,8 +45,11 @@ export async function load({ url, fetch }) {
}
const documents: Document[] = docsResult?.data ?? [];
const allPersons: { id: string; firstName: string; lastName: string }[] =
personsResult.data ?? [];
const allPersons = (personsResult.data ?? []) as {
id: string;
firstName: string;
lastName: string;
}[];
const senderObj = allPersons.find((p) => p.id === senderId);
const receiverObj = allPersons.find((p) => p.id === receiverId);

View File

@@ -2,17 +2,30 @@ import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ url, fetch }) {
export async function load({ url, fetch, locals }) {
const q = url.searchParams.get('q') || '';
const api = createApiClient(fetch);
const result = await api.GET('/api/persons', {
params: { query: { q: q || undefined } }
});
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(undefined));
const [personsResult, statsResult] = await Promise.all([
api.GET('/api/persons', { params: { query: { q: q || undefined } } }),
api.GET('/api/stats', {})
]);
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage(undefined));
}
return { persons: result.data!, q };
const stats = statsResult.response.ok
? {
totalPersons: statsResult.data!.totalPersons ?? 0,
totalDocuments: statsResult.data!.totalDocuments ?? 0
}
: { totalPersons: 0, totalDocuments: 0 };
return { persons: personsResult.data!, stats, q, canWrite };
}

View File

@@ -2,13 +2,15 @@
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import PersonsStatsBar from './PersonsStatsBar.svelte';
import PersonsEmptyState from './PersonsEmptyState.svelte';
let { data } = $props();
let q = $state(untrack(() => data.q || ''));
let qFocused = $state(false);
// Sync URL → local state after navigation, but not while the user is typing.
$effect(() => {
if (!qFocused) q = data.q || '';
});
@@ -28,35 +30,22 @@ function handleSearch() {
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<!-- Header Area -->
<div
class="mb-10 flex flex-col justify-between gap-6 border-b border-ink/10 pb-6 md:flex-row md:items-end"
>
<!-- Header: title+stats on left, search+CTA on right -->
<div class="mb-10 flex flex-wrap items-end justify-between gap-4 border-b border-ink/10 pb-6">
<div>
<h1 class="font-serif text-3xl font-medium text-ink">{m.persons_heading()}</h1>
<p class="mt-2 max-w-xl font-sans text-sm text-ink-2">
{m.persons_subtitle()}
</p>
{#if data.canWrite}
<a
href="/persons/new"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.persons_btn_new()}
</a>
{/if}
<h1 class="font-serif text-3xl font-medium text-ink">{m.page_title_persons()}</h1>
<div class="mt-2">
<PersonsStatsBar
totalPersons={data.stats.totalPersons ?? 0}
totalDocuments={data.stats.totalDocuments ?? 0}
/>
</div>
</div>
<!-- Search Input -->
<div class="w-full md:w-80">
<label for="search" class="sr-only">Suche</label>
<div class="flex items-center gap-3">
<!-- Search -->
<div class="relative">
<label for="search" class="sr-only">Suche</label>
<input
id="search"
type="text"
@@ -65,7 +54,7 @@ function handleSearch() {
oninput={handleSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
class="block w-full rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-ink-3"
@@ -78,55 +67,69 @@ function handleSearch() {
/>
</div>
</div>
<!-- New person CTA -->
{#if data.canWrite}
<a
href="/persons/new"
class="inline-flex items-center gap-1.5 rounded-sm bg-primary px-4 py-2.5 font-sans text-sm font-bold tracking-wide text-primary-fg transition-colors hover:bg-primary/80"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 invert dark:invert-0"
/>
{m.persons_btn_new()}
</a>
{/if}
</div>
</div>
{#if data.persons.length === 0}
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
>
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-ink">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
<p class="font-serif text-lg text-ink">{m.persons_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-ink-2">{m.persons_empty_text()}</p>
</div>
<PersonsEmptyState />
{:else}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each data.persons as person (person.id)}
<a href="/persons/{person.id}" class="group block h-full">
<a href="/persons/{person.id}" class="group block">
<div
class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-line bg-surface p-6 shadow-sm transition-all duration-200 hover:border-primary hover:shadow-md"
class="flex h-full flex-col items-center gap-2 rounded border border-line bg-surface px-4 py-6 text-center shadow-sm transition-all duration-200 hover:border-l-4 hover:border-accent hover:shadow-md"
>
<!-- Decorative Accent on Hover -->
<div
class="absolute top-0 bottom-0 left-0 w-1 bg-primary opacity-0 transition-opacity group-hover:opacity-100"
></div>
<!-- Avatar -->
<div class="flex-shrink-0">
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-primary font-serif text-lg text-primary-fg transition-colors group-hover:bg-accent group-hover:text-ink"
>
{person.firstName[0]}{person.lastName[0]}
</div>
<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]}
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<p class="truncate font-serif text-base font-medium text-ink group-hover:underline">
{person.firstName}
{person.lastName}
<!-- Name -->
<p class="font-serif text-sm font-bold text-ink group-hover:underline">
{person.firstName}
{person.lastName}
</p>
<!-- Alias -->
{#if person.alias}
<p class="font-sans text-xs text-ink-2 italic">{person.alias}"</p>
{/if}
<!-- Life dates -->
{#if person.birthYear || person.deathYear}
<p class="font-sans text-[11px] text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</p>
{#if person.alias}
<p class="mt-0.5 truncate font-sans text-xs text-ink-2">"{person.alias}"</p>
{/if}
</div>
{/if}
<!-- Doc count chip -->
{#if (person.documentCount ?? 0) > 0}
<span
class="mt-1 inline-flex items-center rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-[11px] font-semibold text-ink-2"
>
{person.documentCount === 1
? m.person_card_doc_count_one()
: m.person_card_doc_count_many({ count: person.documentCount ?? 0 })}
</span>
{/if}
</div>
</a>
{/each}

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
</script>
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
>
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-ink">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
<p class="font-serif text-lg text-ink">{m.persons_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-ink-2">{m.persons_empty_text()}</p>
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
totalPersons,
totalDocuments
}: {
totalPersons: number;
totalDocuments: number;
} = $props();
const personsLabel = $derived(
totalPersons === 1 ? m.persons_stats_label_persons_one() : m.persons_stats_label_persons_many()
);
const documentsLabel = $derived(
totalDocuments === 1
? m.persons_stats_label_documents_one()
: m.persons_stats_label_documents_many()
);
</script>
<div class="flex items-baseline gap-4">
<div class="flex items-baseline gap-1.5">
<span class="font-sans text-2xl font-black text-ink">{totalPersons}</span>
<span class="font-sans text-[10px] font-bold tracking-widest text-ink-3 uppercase">
{personsLabel}
</span>
</div>
<span class="font-sans text-lg text-line">·</span>
<div class="flex items-baseline gap-1.5">
<span class="font-sans text-2xl font-black text-ink">{totalDocuments}</span>
<span class="font-sans text-[10px] font-bold tracking-widest text-ink-3 uppercase">
{documentsLabel}
</span>
</div>
</div>

View File

@@ -1,11 +1,16 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ params, fetch }) {
export async function load({ params, fetch, locals }) {
const { id } = params;
const api = createApiClient(fetch);
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([
api.GET('/api/persons/{id}', { params: { path: { id } } }),
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
@@ -20,64 +25,7 @@ export async function load({ params, fetch }) {
return {
person: personResult.data!,
sentDocuments: sentDocsResult.data ?? [],
receivedDocuments: receivedDocsResult.data ?? []
receivedDocuments: receivedDocsResult.data ?? [],
canWrite
};
}
export const actions = {
update: async ({ request, params, fetch }) => {
const formData = await request.formData();
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const notes = formData.get('notes')?.toString().trim() || undefined;
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
if (!firstName || !lastName) {
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
}
const api = createApiClient(fetch);
const { error: apiError } = await api.PUT('/api/persons/{id}', {
params: { path: { id: params.id } },
body: {
firstName,
lastName,
...(alias ? { alias } : {}),
...(notes ? { notes } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {})
}
});
if (apiError) {
return fail(400, { updateError: 'Speichern fehlgeschlagen.' });
}
return { updated: true };
},
merge: async ({ request, params, fetch }) => {
const formData = await request.formData();
const targetPersonId = formData.get('targetPersonId')?.toString();
if (!targetPersonId) {
return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' });
}
const api = createApiClient(fetch);
const { error: apiError } = await api.POST('/api/persons/{id}/merge', {
params: { path: { id: params.id } },
body: { targetPersonId }
});
if (apiError) {
return fail(400, { mergeError: 'Zusammenführen fehlgeschlagen.' });
}
throw redirect(303, `/persons/${targetPersonId}`);
}
};

View File

@@ -2,11 +2,10 @@
import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import PersonCard from './PersonCard.svelte';
import PersonMergePanel from './PersonMergePanel.svelte';
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
import PersonDocumentList from './PersonDocumentList.svelte';
let { data, form } = $props();
let { data } = $props();
const person = $derived(data.person);
const sentDocuments = $derived(data.sentDocuments);
@@ -47,7 +46,7 @@ const coCorrespondents = $derived.by(() => {
});
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<div class="mx-auto max-w-6xl px-4 py-10">
<!-- Back Link -->
<div class="mb-6">
<a
@@ -64,25 +63,30 @@ const coCorrespondents = $derived.by(() => {
</a>
</div>
<PersonCard person={person} canWrite={data.canWrite} form={form} />
<!-- 2-column layout on large screens -->
<div class="lg:grid lg:grid-cols-[35%_65%] lg:gap-8">
<!-- Left column: Person card -->
<div>
<PersonCard person={person} canWrite={data.canWrite} />
</div>
{#if data.canWrite}
{#key person.id}
<PersonMergePanel person={person} form={form} />
{/key}
{/if}
<!-- Right column: correspondents + documents -->
<div>
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
<PersonDocumentList
documents={sentDocuments}
heading={m.person_docs_heading()}
emptyMessage={m.person_no_docs()}
variant="sent"
/>
<PersonDocumentList
documents={sentDocuments}
heading={m.person_docs_heading()}
emptyMessage={m.person_no_docs()}
/>
<PersonDocumentList
documents={receivedDocuments}
heading={m.person_received_docs_heading()}
emptyMessage={m.person_no_received_docs()}
/>
<PersonDocumentList
documents={receivedDocuments}
heading={m.person_received_docs_heading()}
emptyMessage={m.person_no_received_docs()}
variant="received"
/>
</div>
</div>
</div>

View File

@@ -8,21 +8,55 @@ let {
coCorrespondents: { id: string; name: string; count: number }[];
personId: string;
} = $props();
function initials(name: string): string {
return name
.split(' ')
.map((n) => n[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase();
}
</script>
{#if coCorrespondents.length > 0}
<div class="mb-6">
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.person_co_correspondents_heading()}
</h3>
<div class="mb-4 rounded-sm border border-line bg-surface p-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-[10px] font-bold tracking-widest text-ink-3 uppercase">
{m.person_co_correspondents_heading()}
</h3>
<span class="font-sans text-[10px] text-ink-3 italic">{m.person_correspondents_hint()}</span>
</div>
<div class="flex flex-wrap gap-2">
{#each coCorrespondents as c (c.id)}
<a
href="/conversations?senderId={personId}&receiverId={c.id}"
class="inline-flex items-center gap-1.5 rounded-full border border-line px-3 py-1 font-serif text-sm text-ink transition-colors hover:border-primary"
title={m.doc_conversation_title()}
class="inline-flex items-center gap-1.5 rounded-full border border-line bg-muted px-3 py-1.5 font-sans text-xs font-bold text-ink transition-colors hover:border-primary hover:bg-surface"
>
<!-- Initials circle -->
<span
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-primary font-serif text-[9px] font-bold text-primary-fg"
>
{initials(c.name)}
</span>
{c.name}
<span class="font-sans text-xs text-ink-3">({c.count})</span>
<span class="text-[10px] font-normal text-ink-3">×{c.count}</span>
<!-- Chat icon -->
<svg
class="h-3 w-3 flex-shrink-0 text-ink-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</a>
{/each}
</div>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
let {
person,
canWrite,
form
canWrite
}: {
person: {
id: string;
firstName: string;
lastName: string;
alias?: string | null;
@@ -16,231 +16,73 @@ let {
notes?: string | null;
};
canWrite: boolean;
form?: { updated?: boolean; updateError?: string } | null;
} = $props();
let editMode = $state(false);
$effect(() => {
if (form?.updated) editMode = false;
});
</script>
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<div class="h-2 w-full bg-primary"></div>
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<!-- Navy top accent bar -->
<div class="h-1.5 w-full bg-primary"></div>
<div class="p-8 md:p-10">
{#if editMode && canWrite}
<!-- Edit Form -->
<form method="POST" action="?/update" use:enhance>
<div class="flex flex-col gap-6">
<h2 class="border-b border-line-2 pb-3 font-serif text-xl text-ink">
{m.person_edit_heading()}
</h2>
{#if form?.updateError}
<p class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{form.updateError}
</p>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
for="firstName"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="lastName"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_last_name()} *</label
>
<input
id="lastName"
name="lastName"
type="text"
required
value={person.lastName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label
for="alias"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="birthYear"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="deathYear"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label
for="notes"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</label
>
<textarea
id="notes"
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
>{person.notes ?? ''}</textarea
>
</div>
</div>
<div class="flex gap-3">
<button
type="submit"
class="rounded bg-primary px-5 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>
<button
type="button"
onclick={() => (editMode = false)}
class="rounded border border-line px-5 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
>
{m.btn_cancel()}
</button>
</div>
</div>
</form>
{:else}
<!-- View Mode -->
<div class="flex flex-col items-start gap-8 md:flex-row">
<div class="flex-shrink-0">
<div
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
>
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
</div>
</div>
<div class="w-full flex-1">
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
<h1 class="font-serif text-4xl text-ink">
{person.firstName}
{person.lastName}
</h1>
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
{#if canWrite}
<button
onclick={() => (editMode = true)}
class="inline-flex items-center gap-1.5 rounded border border-line px-3 py-1.5 text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
alt=""
aria-hidden="true"
class="h-3.5 w-3.5"
/>
{m.btn_edit()}
</button>
{/if}
</div>
</div>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_full_name()}</span
>
<span class="block font-serif text-lg text-ink"
>{person.firstName} {person.lastName}</span
>
</div>
{#if person.alias}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</span
>
<span class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
</div>
{/if}
{#if person.birthYear || person.deathYear}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>
{#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if}
</span>
<span class="block font-serif text-lg text-ink">
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
&nbsp;{/if}{#if person.deathYear}{person.deathYear}{/if}
</span>
</div>
{/if}
{#if person.notes}
<div class="md:col-span-2">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</span
>
<p class="font-serif text-base whitespace-pre-wrap text-ink">
{person.notes}
</p>
</div>
{/if}
</div>
</div>
<div class="p-6">
<!-- Avatar — navy circle, centered -->
<div class="mb-4 flex justify-center">
<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]}
</div>
</div>
<!-- Name — centered, serif -->
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
{person.firstName}
{person.lastName}
</h1>
<!-- Alias — centered, italic -->
{#if person.alias}
<p class="mb-1 text-center font-sans text-sm text-ink-2 italic">{person.alias}"</p>
{/if}
<!-- Life dates — centered -->
{#if person.birthYear || person.deathYear}
<p class="mb-4 text-center font-sans text-sm text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</p>
{:else}
<div class="mb-4"></div>
{/if}
<!-- Notes -->
{#if person.notes}
<div class="mb-4">
<span
class="mb-1 block font-sans text-[10px] font-bold tracking-widest text-ink-3 uppercase"
>
{m.person_label_notes()}
</span>
<p
class="rounded border border-line bg-muted p-3 font-sans text-sm leading-relaxed text-ink-2"
>
{person.notes}
</p>
</div>
{/if}
<!-- Edit button — full width, outlined -->
{#if canWrite}
<a
href="/persons/{person.id}/edit"
class="flex w-full items-center justify-center gap-1.5 rounded border border-line px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
alt=""
aria-hidden="true"
class="h-3.5 w-3.5"
/>
{m.btn_edit()}
</a>
{/if}
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
const DOCS_PREVIEW_LIMIT = 5;
@@ -8,7 +9,8 @@ const DOCS_PREVIEW_LIMIT = 5;
let {
documents,
heading,
emptyMessage
emptyMessage,
variant = 'sent'
}: {
documents: {
id: string;
@@ -20,6 +22,7 @@ let {
}[];
heading: string;
emptyMessage: string;
variant?: 'sent' | 'received';
} = $props();
const yearRange = $derived.by(() => {
@@ -39,21 +42,29 @@ const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
const visibleDocuments = $derived(
showAll ? sortedDocuments : sortedDocuments.slice(0, DOCS_PREVIEW_LIMIT)
);
// Spec: sent = navy-tint icon bg, received = teal-tint icon bg
const iconClasses = $derived(
variant === 'sent' ? 'bg-primary/10 text-primary' : 'bg-accent/20 text-accent-fg'
);
</script>
<div class="mb-10">
<div class="mb-6 flex items-center gap-3 border-b border-ink/10 pb-2">
<h2 class="font-serif text-xl text-ink">{heading}</h2>
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-primary-fg">
<div class="mb-4 rounded-sm border border-line bg-surface">
<!-- Section header -->
<div class="flex items-center gap-3 border-b border-line px-4 py-3">
<h2 class="text-[10px] font-bold tracking-widest text-ink-3 uppercase">{heading}</h2>
<span
class="rounded-full bg-primary/10 px-2 py-0.5 font-sans text-[10px] font-bold text-primary"
>
{documents.length}
</span>
{#if yearRange}
<span class="font-sans text-xs text-ink-3">{yearRange}</span>
<span class="font-sans text-[10px] text-ink-3">{yearRange}</span>
{/if}
{#if documents.length > 1}
<button
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
class="ml-auto text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
class="ml-auto text-[10px] font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
>
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
</button>
@@ -61,69 +72,68 @@ const visibleDocuments = $derived(
</div>
{#if documents.length === 0}
<div class="rounded-sm border border-dashed border-line bg-surface p-12 text-center">
<p class="font-sans text-ink-2">{emptyMessage}</p>
</div>
<p class="px-4 py-6 text-center font-sans text-sm text-ink-3">{emptyMessage}</p>
{:else}
<ul class="space-y-3">
<ul>
{#each visibleDocuments as doc (doc.id)}
<li class="group">
<li>
<a
href="/documents/{doc.id}"
class="block border border-line bg-surface p-4 transition-all duration-200 hover:border-primary hover:shadow-md"
class="group flex items-center gap-3 border-b border-line px-4 py-3 transition-colors last:border-b-0 hover:bg-muted"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 overflow-hidden">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-muted text-ink transition-colors group-hover:bg-accent group-hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</div>
<div class="min-w-0">
<div
class="truncate font-serif text-base font-medium text-ink group-hover:underline"
>
{doc.title || doc.originalFilename}
</div>
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-ink-2">
<span>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span>
{#if doc.location}
<span class="text-accent"></span>
<span>{doc.location}</span>
{/if}
</div>
</div>
<!-- Tinted doc icon -->
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded {iconClasses}"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
</div>
<!-- Title + meta -->
<div class="min-w-0 flex-1">
<div class="truncate font-serif text-sm font-medium text-ink group-hover:underline">
{doc.title || doc.originalFilename}
</div>
<div class="flex flex-shrink-0 items-center gap-2 pl-4">
<span
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
{doc.status === 'UPLOADED'
? 'border-accent/50 bg-accent/20 text-ink'
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
>
{doc.status}
</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="ml-2 h-5 w-5 opacity-40 transition-opacity group-hover:opacity-100"
/>
<div class="mt-0.5 flex items-center gap-1.5 font-sans text-[11px] text-ink-3">
<span>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span>
{#if doc.location}
<span>·</span>
<span>{doc.location}</span>
{/if}
</div>
</div>
<!-- Status chip -->
<span
class="hidden flex-shrink-0 rounded-full border px-2 py-0.5 font-sans text-[10px] font-bold sm:inline-block
{doc.status === 'UPLOADED'
? 'border-accent/50 bg-accent/20 text-ink'
: doc.status === 'ARCHIVED'
? 'border-green-200 bg-green-50 text-green-800'
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
>
{formatDocumentStatus(doc.status)}
</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 flex-shrink-0 opacity-30 transition-opacity group-hover:opacity-80"
/>
</a>
</li>
{/each}
</ul>
{#if documents.length > DOCS_PREVIEW_LIMIT && !showAll}
<button
onclick={() => (showAll = true)}
class="mt-3 text-xs font-bold tracking-widest text-ink/50 uppercase transition-colors hover:text-ink"
class="w-full border-t border-line py-2.5 text-center font-sans text-[11px] font-bold tracking-widest text-ink/50 uppercase transition-colors hover:text-ink"
>
{m.person_show_more({ count: documents.length - DOCS_PREVIEW_LIMIT })}
</button>

View File

@@ -0,0 +1,87 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ params, fetch, locals }) {
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
const { id } = params;
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
}
export const actions = {
update: async ({ request, params, fetch }) => {
const formData = await request.formData();
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const notes = formData.get('notes')?.toString().trim() || undefined;
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
if (!firstName || !lastName) {
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
}
const api = createApiClient(fetch);
const result = await api.PUT('/api/persons/{id}', {
params: { path: { id: params.id } },
body: {
firstName,
lastName,
...(alias ? { alias } : {}),
...(notes ? { notes } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {})
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { updateError: getErrorMessage(code) });
}
throw redirect(303, `/persons/${params.id}`);
},
discard: async ({ params }) => {
throw redirect(303, `/persons/${params.id}`);
},
merge: async ({ request, params, fetch }) => {
const formData = await request.formData();
const targetPersonId = formData.get('targetPersonId')?.toString();
if (!targetPersonId) {
return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' });
}
const api = createApiClient(fetch);
const result = await api.POST('/api/persons/{id}/merge', {
params: { path: { id: params.id } },
body: { targetPersonId }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { mergeError: getErrorMessage(code) });
}
throw redirect(303, `/persons/${targetPersonId}`);
}
};

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { enhance } from '$app/forms';
import PersonEditForm from './PersonEditForm.svelte';
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
import PersonDangerZone from './PersonDangerZone.svelte';
let { data, form } = $props();
const person = $derived(data.person);
</script>
<div class="mx-auto max-w-2xl px-4 py-8">
<!-- Back link -->
<div class="mb-6">
<a
href="/persons/{person.id}"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
{person.firstName}
{person.lastName}
</a>
<h1 class="font-serif text-3xl text-ink">{m.person_edit_heading()}</h1>
</div>
{#if form?.updateError}
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">
{form.updateError}
</div>
{/if}
<form method="POST" use:enhance>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.persons_section_details()}
</h2>
<PersonEditForm person={person} />
</div>
<PersonDangerZone person={person} form={form} />
<PersonEditSaveBar discardHref="/persons/{person.id}" />
</form>
</div>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PersonMergePanel from '../PersonMergePanel.svelte';
let {
person,
form
}: {
person: { id: string; firstName: string; lastName: string };
form?: { mergeError?: string } | null;
} = $props();
let open = $state(false);
</script>
<div class="mt-8 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
<button
type="button"
onclick={() => (open = !open)}
class="flex w-full items-center justify-between px-6 py-4 text-left"
aria-expanded={open}
>
<span class="text-sm font-bold tracking-widest text-red-600 uppercase">
{m.person_danger_zone_heading()}
</span>
<svg
class="h-4 w-4 text-red-400 transition-transform {open ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if open}
<div class="border-t border-red-100 px-6 py-4">
{#key person.id}
<PersonMergePanel person={person} form={form} />
{/key}
</div>
{/if}
</div>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
person
}: {
person: {
firstName: string;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
notes?: string | null;
};
} = $props();
</script>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="firstName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_last_name()} *</label
>
<input
id="lastName"
name="lastName"
type="text"
required
value={person.lastName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label for="birthYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label for="deathYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</label
>
<textarea
id="notes"
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
>{person.notes ?? ''}</textarea
>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { discardHref }: { discardHref: string } = $props();
</script>
<!-- Sticky full-bleed save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
>
<a href={discardHref} class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
{m.person_discard_changes()}
</a>
<button
type="submit"
formaction="?/update"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
>
{m.person_save_changes()}
</button>
</div>

View File

@@ -6,6 +6,10 @@ vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
import { createApiClient } from '$lib/api.server';
const mockFetch = vi.fn() as unknown as typeof fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockLocals = { user: { groups: [{ permissions: ['READ_ALL'] }] } } as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockLocalsWriter = { user: { groups: [{ permissions: ['WRITE_ALL'] }] } } as any;
beforeEach(() => vi.clearAllMocks());
@@ -24,13 +28,30 @@ describe('person detail load — happy path', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
const result = await load({ params: { id: 'p1' }, fetch: mockFetch });
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
expect(result.person.firstName).toBe('Hans');
expect(result.sentDocuments).toHaveLength(1);
expect(result.receivedDocuments).toEqual([]);
});
it('returns canWrite=true when user has WRITE_ALL', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi
.fn()
.mockResolvedValueOnce({
response: { ok: true, status: 200 },
data: { id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }
})
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
expect(result.canWrite).toBe(true);
});
it('returns empty arrays when sent/received document APIs fail', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi
@@ -43,7 +64,7 @@ describe('person detail load — happy path', () => {
.mockResolvedValueOnce({ response: { ok: false }, data: null })
} as ReturnType<typeof createApiClient>);
const result = await load({ params: { id: 'p1' }, fetch: mockFetch });
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
expect(result.sentDocuments).toEqual([]);
expect(result.receivedDocuments).toEqual([]);
@@ -62,7 +83,9 @@ describe('person detail load — error paths', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
await expect(load({ params: { id: 'missing' }, fetch: mockFetch })).rejects.toMatchObject({
await expect(
load({ params: { id: 'missing' }, fetch: mockFetch, locals: mockLocals })
).rejects.toMatchObject({
status: 404
});
});
@@ -76,7 +99,9 @@ describe('person detail load — error paths', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
await expect(load({ params: { id: 'forbidden' }, fetch: mockFetch })).rejects.toMatchObject({
await expect(
load({ params: { id: 'forbidden' }, fetch: mockFetch, locals: mockLocals })
).rejects.toMatchObject({
status: 403
});
});

View File

@@ -15,14 +15,27 @@ export const actions = {
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
const notes = formData.get('notes')?.toString().trim() || undefined;
if (!firstName || !lastName) {
return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' });
}
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
const api = createApiClient(fetch);
const result = await api.POST('/api/persons', {
body: { firstName, lastName, ...(alias ? { alias } : {}) }
body: {
firstName,
lastName,
...(alias ? { alias } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {}),
...(notes ? { notes } : {})
}
});
if (!result.response.ok) {

View File

@@ -77,6 +77,49 @@ let { form } = $props();
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<div>
<label for="birthYear" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<div>
<label for="deathYear" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<div class="md:col-span-2">
<label for="notes" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_notes()}</label
>
<textarea
id="notes"
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
></textarea>
</div>
</div>
</div>

View File

@@ -11,11 +11,24 @@ const makePerson = (overrides = {}) => ({
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
documentCount: 0,
...overrides
});
const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] };
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
const defaultStats = { totalPersons: 0, totalDocuments: 0 };
const emptyData = {
user: undefined,
canWrite: true,
canAnnotate: false,
q: '',
persons: [],
stats: defaultStats
};
const dataWithPersons = {
...emptyData,
persons: [makePerson()],
stats: { totalPersons: 1, totalDocuments: 3 }
};
afterEach(cleanup);
@@ -48,6 +61,22 @@ describe('Persons page rendering', () => {
.element(page.getByRole('link', { name: /Max Mustermann/ }))
.toHaveAttribute('href', '/persons/1');
});
it('shows alias in italic when provided', async () => {
render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } });
await expect.element(page.getByText('"Maxi"')).toBeInTheDocument();
});
it('shows life date range when birthYear is provided', async () => {
render(Page, { data: { ...emptyData, persons: [makePerson({ birthYear: 1900 })] } });
await expect.element(page.getByText('* 1900')).toBeInTheDocument();
});
it('shows stats bar with person and document counts', async () => {
render(Page, { data: dataWithPersons });
await expect.element(page.getByText(/1 Person/)).toBeInTheDocument();
await expect.element(page.getByText(/3 Dokumente/)).toBeInTheDocument();
});
});
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────