feat: metadata enrichment queue (#67) #77
@@ -4,6 +4,8 @@ import java.io.IOException;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -156,6 +158,23 @@ public class DocumentController {
|
|||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/incomplete-count")
|
||||||
|
public Map<String, Long> getIncompleteCount() {
|
||||||
|
return Map.of("count", documentService.getIncompleteCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/incomplete")
|
||||||
|
public List<Document> getIncomplete() {
|
||||||
|
return documentService.findIncompleteDocuments();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/incomplete/next")
|
||||||
|
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||||
|
return documentService.findNextIncompleteDocument(excludeId)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<List<Document>> search(
|
public ResponseEntity<List<Document>> search(
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ public class DocumentUpdateDTO {
|
|||||||
private UUID senderId;
|
private UUID senderId;
|
||||||
private List<UUID> receiverIds;
|
private List<UUID> receiverIds;
|
||||||
private String tags;
|
private String tags;
|
||||||
|
private Boolean metadataComplete;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ public class Document {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "metadata_complete", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean metadataComplete = false;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|
||||||
|
long countByMetadataCompleteFalse();
|
||||||
|
|
||||||
|
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||||
|
|
||||||
|
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||||
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
|
|||||||
@@ -62,10 +62,12 @@ public class DocumentService {
|
|||||||
if (existingDoc.isPresent()) {
|
if (existingDoc.isPresent()) {
|
||||||
document = existingDoc.get();
|
document = existingDoc.get();
|
||||||
} else {
|
} else {
|
||||||
|
// New uploads from the drop zone always start as incomplete
|
||||||
document = Document.builder()
|
document = Document.builder()
|
||||||
.originalFilename(originalFilename)
|
.originalFilename(originalFilename)
|
||||||
.title(stripExtension(originalFilename))
|
.title(stripExtension(originalFilename))
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.metadataComplete(false)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,17 @@ public class DocumentService {
|
|||||||
? file.getOriginalFilename()
|
? file.getOriginalFilename()
|
||||||
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");
|
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");
|
||||||
|
|
||||||
|
// If the caller explicitly sets metadataComplete, use it.
|
||||||
|
// Otherwise apply heuristic: complete if at least one key field is present.
|
||||||
|
boolean metadataComplete;
|
||||||
|
if (dto.getMetadataComplete() != null) {
|
||||||
|
metadataComplete = dto.getMetadataComplete();
|
||||||
|
} else {
|
||||||
|
metadataComplete = dto.getDocumentDate() != null
|
||||||
|
|| dto.getSenderId() != null
|
||||||
|
|| (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
Document doc = Document.builder()
|
Document doc = Document.builder()
|
||||||
.originalFilename(filename)
|
.originalFilename(filename)
|
||||||
.title(dto.getTitle())
|
.title(dto.getTitle())
|
||||||
@@ -98,6 +111,7 @@ public class DocumentService {
|
|||||||
.transcription(dto.getTranscription())
|
.transcription(dto.getTranscription())
|
||||||
.summary(dto.getSummary())
|
.summary(dto.getSummary())
|
||||||
.status(DocumentStatus.PLACEHOLDER)
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.metadataComplete(metadataComplete)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
doc = documentRepository.save(doc);
|
doc = documentRepository.save(doc);
|
||||||
@@ -176,6 +190,11 @@ public class DocumentService {
|
|||||||
doc.getReceivers().clear(); // Alle entfernen
|
doc.getReceivers().clear(); // Alle entfernen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3b. metadataComplete — only update when explicitly set in the DTO
|
||||||
|
if (dto.getMetadataComplete() != null) {
|
||||||
|
doc.setMetadataComplete(dto.getMetadataComplete());
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||||
if (newFile != null && !newFile.isEmpty()) {
|
if (newFile != null && !newFile.isEmpty()) {
|
||||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
@@ -280,6 +299,19 @@ public class DocumentService {
|
|||||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getIncompleteCount() {
|
||||||
|
return documentRepository.countByMetadataCompleteFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Document> findIncompleteDocuments() {
|
||||||
|
return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
|
||||||
|
return documentRepository.findFirstByMetadataCompleteFalseAndIdNot(
|
||||||
|
currentId, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteDocument(UUID id) {
|
public void deleteDocument(UUID id) {
|
||||||
if (!documentRepository.existsById(id)) {
|
if (!documentRepository.existsById(id)) {
|
||||||
|
|||||||
@@ -312,6 +312,9 @@ public class MassImportService {
|
|||||||
.originalFilename(originalFilename)
|
.originalFilename(originalFilename)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
// Heuristic: mark as complete if at least one key field is present in the spreadsheet row
|
||||||
|
boolean metadataComplete = date != null || !senderRaw.isBlank() || !receiversRaw.isBlank();
|
||||||
|
|
||||||
doc.setTitle(buildTitle(index, date, location));
|
doc.setTitle(buildTitle(index, date, location));
|
||||||
doc.setFilePath(s3Key);
|
doc.setFilePath(s3Key);
|
||||||
doc.setContentType(contentType);
|
doc.setContentType(contentType);
|
||||||
@@ -325,6 +328,7 @@ public class MassImportService {
|
|||||||
doc.setSender(sender);
|
doc.setSender(sender);
|
||||||
doc.getReceivers().addAll(receivers);
|
doc.getReceivers().addAll(receivers);
|
||||||
if (tag != null) doc.getTags().add(tag);
|
if (tag != null) doc.getTags().add(tag);
|
||||||
|
doc.setMetadataComplete(metadataComplete);
|
||||||
|
|
||||||
documentRepository.save(doc);
|
documentRepository.save(doc);
|
||||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add metadata_complete flag to documents.
|
||||||
|
-- Existing rows default to true (already reviewed before this feature existed).
|
||||||
|
-- New documents created via Java will receive false from the entity default.
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN metadata_complete BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
@@ -21,6 +21,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -212,6 +213,78 @@ class DocumentControllerTest {
|
|||||||
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
|
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getIncompleteCount_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getIncompleteCount_returns200_withCount() throws Exception {
|
||||||
|
when(documentService.getIncompleteCount()).thenReturn(3L);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getIncomplete_returns200_withList() throws Exception {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build();
|
||||||
|
when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNextIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||||
|
.param("excludeId", UUID.randomUUID().toString()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getNextIncomplete_returns200_whenNextExists() throws Exception {
|
||||||
|
UUID excludeId = UUID.randomUUID();
|
||||||
|
Document next = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build();
|
||||||
|
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||||
|
.param("excludeId", excludeId.toString()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.title").value("Nächster"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
|
||||||
|
UUID excludeId = UUID.randomUUID();
|
||||||
|
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||||
|
.param("excludeId", excludeId.toString()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
@@ -11,7 +12,10 @@ import org.raddatz.familienarchiv.model.Document;
|
|||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -344,6 +348,162 @@ class DocumentServiceTest {
|
|||||||
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
|
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── getIncompleteCount ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getIncompleteCount_delegatesToRepository() {
|
||||||
|
when(documentRepository.countByMetadataCompleteFalse()).thenReturn(5L);
|
||||||
|
assertThat(documentService.getIncompleteCount()).isEqualTo(5L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findIncompleteDocuments ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
|
||||||
|
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
||||||
|
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
|
||||||
|
|
||||||
|
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
|
||||||
|
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findNextIncompleteDocument ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findNextIncompleteDocument_returnsNext_whenAnotherIncompleteExists() {
|
||||||
|
UUID currentId = UUID.randomUUID();
|
||||||
|
Document next = Document.builder().id(UUID.randomUUID()).title("Next").build();
|
||||||
|
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
|
||||||
|
.thenReturn(Optional.of(next));
|
||||||
|
|
||||||
|
assertThat(documentService.findNextIncompleteDocument(currentId)).contains(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findNextIncompleteDocument_returnsEmpty_whenNoMoreIncomplete() {
|
||||||
|
UUID currentId = UUID.randomUUID();
|
||||||
|
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThat(documentService.findNextIncompleteDocument(currentId)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── storeDocument metadataComplete ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_setsMetadataCompleteFalse_forNewDocument() throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf").build();
|
||||||
|
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||||
|
|
||||||
|
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
verify(documentRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().isMetadataComplete()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_doesNotChangeMetadataComplete_forExistingDocument() throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||||
|
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER).metadataComplete(true).build();
|
||||||
|
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
when(documentRepository.save(any())).thenReturn(existing);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||||
|
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
assertThat(existing.isMetadataComplete()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createDocument metadataComplete ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsMetadataCompleteFromDto_whenExplicitlyProvided() throws Exception {
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Doc");
|
||||||
|
dto.setMetadataComplete(true);
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||||
|
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||||
|
|
||||||
|
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||||
|
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsMetadataCompleteFalse_whenAllKeyFieldsMissingAndNoExplicitFlag() throws Exception {
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Doc");
|
||||||
|
// no documentDate, no senderId, no receiverIds, no metadataComplete flag
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||||
|
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||||
|
|
||||||
|
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||||
|
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsMetadataCompleteTrue_whenDatePresentAndNoExplicitFlag() throws Exception {
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Doc");
|
||||||
|
dto.setDocumentDate(LocalDate.of(2020, 1, 1));
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||||
|
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||||
|
|
||||||
|
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||||
|
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateDocument metadataComplete ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsMetadataComplete_whenDtoHasValue() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(documentRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setMetadataComplete(true);
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(existing.isMetadataComplete()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_doesNotChangeMetadataComplete_whenDtoHasNull() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(documentRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
// metadataComplete not set → null
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(existing.isMetadataComplete()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
|
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
|
||||||
UUID id1 = UUID.randomUUID();
|
UUID id1 = UUID.randomUUID();
|
||||||
|
|||||||
@@ -274,5 +274,21 @@
|
|||||||
"upload_duplicate": "{filename} existiert bereits —",
|
"upload_duplicate": "{filename} existiert bereits —",
|
||||||
"upload_duplicate_link": "Zum Dokument",
|
"upload_duplicate_link": "Zum Dokument",
|
||||||
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
||||||
"upload_error": "Fehler beim Hochladen von {filename}"
|
"upload_error": "Fehler beim Hochladen von {filename}",
|
||||||
|
"enrich_list_back": "Zurück zur Übersicht",
|
||||||
|
"enrich_list_count": "Dokumente",
|
||||||
|
"btn_save_and_mark_reviewed": "Speichern & abschließen",
|
||||||
|
"btn_mark_for_review": "Zur Überprüfung markieren",
|
||||||
|
"enrich_needs_metadata_title": "Dokumente ohne Metadaten",
|
||||||
|
"enrich_needs_metadata_count": "{count} Dokument(e) warten auf Metadaten",
|
||||||
|
"enrich_needs_metadata_cta": "Jetzt vervollständigen",
|
||||||
|
"enrich_list_heading": "Dokumente ohne Metadaten",
|
||||||
|
"enrich_list_empty_heading": "Alle Dokumente vollständig",
|
||||||
|
"enrich_list_empty_body": "Es gibt keine Dokumente, die noch Metadaten benötigen.",
|
||||||
|
"enrich_list_start": "Überprüfung starten",
|
||||||
|
"enrich_progress": "{count} verbleibend",
|
||||||
|
"enrich_skip": "Überspringen",
|
||||||
|
"enrich_done_heading": "Alles erledigt!",
|
||||||
|
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
|
||||||
|
"enrich_back_to_list": "Zurück zur Liste"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,5 +274,21 @@
|
|||||||
"upload_duplicate": "{filename} already exists —",
|
"upload_duplicate": "{filename} already exists —",
|
||||||
"upload_duplicate_link": "View document",
|
"upload_duplicate_link": "View document",
|
||||||
"upload_invalid_type": "{filename}: unsupported file format",
|
"upload_invalid_type": "{filename}: unsupported file format",
|
||||||
"upload_error": "Error uploading {filename}"
|
"upload_error": "Error uploading {filename}",
|
||||||
|
"enrich_list_back": "Back to overview",
|
||||||
|
"enrich_list_count": "documents",
|
||||||
|
"btn_save_and_mark_reviewed": "Save & mark as reviewed",
|
||||||
|
"btn_mark_for_review": "Mark for review",
|
||||||
|
"enrich_needs_metadata_title": "Documents without metadata",
|
||||||
|
"enrich_needs_metadata_count": "{count} document(s) waiting for metadata",
|
||||||
|
"enrich_needs_metadata_cta": "Complete now",
|
||||||
|
"enrich_list_heading": "Documents without metadata",
|
||||||
|
"enrich_list_empty_heading": "All documents complete",
|
||||||
|
"enrich_list_empty_body": "There are no documents that still need metadata.",
|
||||||
|
"enrich_list_start": "Start reviewing",
|
||||||
|
"enrich_progress": "{count} remaining",
|
||||||
|
"enrich_skip": "Skip",
|
||||||
|
"enrich_done_heading": "All done!",
|
||||||
|
"enrich_done_body": "All documents have been processed.",
|
||||||
|
"enrich_back_to_list": "Back to list"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,5 +274,21 @@
|
|||||||
"upload_duplicate": "{filename} ya existe —",
|
"upload_duplicate": "{filename} ya existe —",
|
||||||
"upload_duplicate_link": "Ver documento",
|
"upload_duplicate_link": "Ver documento",
|
||||||
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
||||||
"upload_error": "Error al subir {filename}"
|
"upload_error": "Error al subir {filename}",
|
||||||
|
"enrich_list_back": "Volver a la vista general",
|
||||||
|
"enrich_list_count": "documentos",
|
||||||
|
"btn_save_and_mark_reviewed": "Guardar y marcar como revisado",
|
||||||
|
"btn_mark_for_review": "Marcar para revisión",
|
||||||
|
"enrich_needs_metadata_title": "Documentos sin metadatos",
|
||||||
|
"enrich_needs_metadata_count": "{count} documento(s) esperando metadatos",
|
||||||
|
"enrich_needs_metadata_cta": "Completar ahora",
|
||||||
|
"enrich_list_heading": "Documentos sin metadatos",
|
||||||
|
"enrich_list_empty_heading": "Todos los documentos completos",
|
||||||
|
"enrich_list_empty_body": "No hay documentos que necesiten metadatos.",
|
||||||
|
"enrich_list_start": "Comenzar revisión",
|
||||||
|
"enrich_progress": "{count} restante(s)",
|
||||||
|
"enrich_skip": "Omitir",
|
||||||
|
"enrich_done_heading": "¡Todo listo!",
|
||||||
|
"enrich_done_body": "Todos los documentos han sido procesados.",
|
||||||
|
"enrich_back_to_list": "Volver a la lista"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,6 +468,54 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/incomplete-count": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getIncompleteCount"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/incomplete": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getIncomplete"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/incomplete/next": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getNextIncomplete"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/search": {
|
"/api/documents/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1819,6 +1867,77 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getIncompleteCount: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getIncomplete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Document"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getNextIncomplete: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
excludeId: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Document"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
importStatus: {
|
importStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function load({ url, fetch }) {
|
|||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [docsResult, personsResult] = await Promise.all([
|
const [docsResult, personsResult, incompleteCountResult] = await Promise.all([
|
||||||
api.GET('/api/documents/search', {
|
api.GET('/api/documents/search', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
@@ -25,7 +25,8 @@ export async function load({ url, fetch }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
api.GET('/api/persons')
|
api.GET('/api/persons'),
|
||||||
|
api.GET('/api/documents/incomplete-count')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||||
@@ -39,8 +40,13 @@ export async function load({ url, fetch }) {
|
|||||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||||
|
|
||||||
|
const incompleteCount = incompleteCountResult.response.ok
|
||||||
|
? (incompleteCountResult.data?.count ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
|
incompleteCount,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||||
@@ -53,6 +59,7 @@ export async function load({ url, fetch }) {
|
|||||||
console.error('Error loading data:', e);
|
console.error('Error loading data:', e);
|
||||||
return {
|
return {
|
||||||
documents: [],
|
documents: [],
|
||||||
|
incompleteCount: 0,
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
filters: { q, from, to, senderId, receiverId, tags },
|
filters: { q, from, to, senderId, receiverId, tags },
|
||||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|||||||
import SearchFilterBar from './SearchFilterBar.svelte';
|
import SearchFilterBar from './SearchFilterBar.svelte';
|
||||||
import DropZone from './DropZone.svelte';
|
import DropZone from './DropZone.svelte';
|
||||||
import DocumentList from './DocumentList.svelte';
|
import DocumentList from './DocumentList.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -86,5 +87,34 @@ $effect(() => {
|
|||||||
<DropZone />
|
<DropZone />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if data.incompleteCount > 0}
|
||||||
|
<a
|
||||||
|
href="/enrich"
|
||||||
|
class="mb-6 flex items-center justify-between rounded-sm border border-brand-mint/40 bg-brand-mint/10 px-6 py-4 transition-colors hover:bg-brand-mint/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Info/Block/Info-Block-Border-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-6 w-6 opacity-60"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.enrich_needs_metadata_title()}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 font-serif text-sm text-brand-navy/70">
|
||||||
|
{m.enrich_needs_metadata_count({ count: data.incompleteCount })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:text-brand-navy/70"
|
||||||
|
>
|
||||||
|
{m.enrich_needs_metadata_cta()} →
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -60,6 +60,53 @@ export const actions = {
|
|||||||
throw redirect(303, `/documents/${params.id}`);
|
throw redirect(303, `/documents/${params.id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markForReview: async ({
|
||||||
|
params,
|
||||||
|
fetch
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
}) => {
|
||||||
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
|
// Fetch current document to preserve all existing fields
|
||||||
|
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } });
|
||||||
|
if (!docResult.response.ok) {
|
||||||
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
|
return fail(docResult.response.status, { error: getErrorMessage(code) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = docResult.data!;
|
||||||
|
const formData = new FormData();
|
||||||
|
if (doc.title) formData.set('title', doc.title);
|
||||||
|
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
|
||||||
|
if (doc.location) formData.set('location', doc.location);
|
||||||
|
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
|
||||||
|
if (doc.transcription) formData.set('transcription', doc.transcription);
|
||||||
|
if (doc.summary) formData.set('summary', doc.summary);
|
||||||
|
if (doc.sender?.id) formData.set('senderId', doc.sender.id);
|
||||||
|
if (doc.receivers?.length) {
|
||||||
|
doc.receivers.forEach((r: { id: string }) => formData.append('receiverIds', r.id));
|
||||||
|
}
|
||||||
|
if (doc.tags?.length) {
|
||||||
|
formData.set('tags', doc.tags.map((t: { name: string }) => t.name).join(','));
|
||||||
|
}
|
||||||
|
formData.set('metadataComplete', 'false');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const backendError = await parseBackendError(res);
|
||||||
|
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw redirect(303, `/documents/${params.id}`);
|
||||||
|
},
|
||||||
|
|
||||||
delete: async ({ params, fetch }) => {
|
delete: async ({ params, fetch }) => {
|
||||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
let { docId }: { docId: string } = $props();
|
let { docId }: { docId: string } = $props();
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ let confirmDelete = $state(false);
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: cancel + save -->
|
<!-- Right: cancel + mark for review + save -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="/documents/{docId}"
|
href="/documents/{docId}"
|
||||||
@@ -62,6 +63,13 @@ let confirmDelete = $state(false);
|
|||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</a>
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="mark-for-review-form"
|
||||||
|
class="rounded-sm border border-gray-300 px-4 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{m.btn_mark_for_review()}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
||||||
@@ -70,3 +78,5 @@ let confirmDelete = $state(false);
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form id="mark-for-review-form" method="POST" action="?/markForReview" use:enhance></form>
|
||||||
|
|||||||
@@ -55,22 +55,41 @@ export async function load({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitNewDocument(
|
||||||
|
request: Request,
|
||||||
|
fetch: typeof globalThis.fetch,
|
||||||
|
metadataComplete: boolean
|
||||||
|
) {
|
||||||
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
|
const formData = await request.formData();
|
||||||
|
formData.set('metadataComplete', String(metadataComplete));
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const backendError = await parseBackendError(res);
|
||||||
|
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await res.json();
|
||||||
|
throw redirect(303, `/documents/${created.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, fetch }) => {
|
save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
|
||||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
return submitNewDocument(request, fetch, false);
|
||||||
const formData = await request.formData();
|
},
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/documents`, {
|
saveReviewed: async ({
|
||||||
method: 'POST',
|
request,
|
||||||
body: formData
|
fetch
|
||||||
});
|
}: {
|
||||||
|
request: Request;
|
||||||
if (!res.ok) {
|
fetch: typeof globalThis.fetch;
|
||||||
const backendError = await parseBackendError(res);
|
}) => {
|
||||||
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
return submitNewDocument(request, fetch, true);
|
||||||
}
|
|
||||||
|
|
||||||
const created = await res.json();
|
|
||||||
throw redirect(303, `/documents/${created.id}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,12 +62,26 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
|
|||||||
<a href="/" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
|
<a href="/" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<div class="flex items-center gap-3">
|
||||||
type="submit"
|
<button
|
||||||
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
type="submit"
|
||||||
>
|
name="metadataComplete"
|
||||||
{m.btn_save()}
|
value="false"
|
||||||
</button>
|
formaction="?/save"
|
||||||
|
class="rounded-sm border border-gray-300 px-5 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="metadataComplete"
|
||||||
|
value="true"
|
||||||
|
formaction="?/saveReviewed"
|
||||||
|
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||||
|
>
|
||||||
|
{m.btn_save_and_mark_reviewed()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
frontend/src/routes/enrich/+page.server.ts
Normal file
23
frontend/src/routes/enrich/+page.server.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
|
export async function load({
|
||||||
|
fetch,
|
||||||
|
locals
|
||||||
|
}: {
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
locals: App.Locals;
|
||||||
|
}) {
|
||||||
|
const canWrite =
|
||||||
|
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||||
|
g.permissions.includes('WRITE_ALL')
|
||||||
|
) ?? false;
|
||||||
|
if (!canWrite) throw redirect(303, '/');
|
||||||
|
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.GET('/api/documents/incomplete');
|
||||||
|
|
||||||
|
const documents = result.response.ok ? (result.data ?? []) : [];
|
||||||
|
|
||||||
|
return { documents };
|
||||||
|
}
|
||||||
106
frontend/src/routes/enrich/+page.svelte
Normal file
106
frontend/src/routes/enrich/+page.svelte
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const documents = $derived(data.documents);
|
||||||
|
const count = $derived(documents.length);
|
||||||
|
|
||||||
|
function formatUploadDate(createdAt: string): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(new Date(createdAt));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||||
|
<!-- Back Link -->
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="group mb-4 inline-flex items-center font-sans text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
|
/>
|
||||||
|
{m.enrich_list_back()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="border-brand-sand mb-8 flex items-center justify-between border-b pb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-serif text-3xl font-medium text-brand-navy">
|
||||||
|
{m.enrich_list_heading()}
|
||||||
|
</h1>
|
||||||
|
{#if count > 0}
|
||||||
|
<p class="mt-2 font-sans text-sm text-brand-navy/60">
|
||||||
|
{count}
|
||||||
|
{m.enrich_list_count()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if count > 0}
|
||||||
|
<a
|
||||||
|
href="/enrich/{documents[0].id}"
|
||||||
|
class="bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||||
|
>
|
||||||
|
{m.enrich_list_start()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
{#if count === 0}
|
||||||
|
<div
|
||||||
|
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border border-dashed bg-white py-20 text-center"
|
||||||
|
>
|
||||||
|
<div class="bg-brand-sand/60 mb-4 flex h-14 w-14 items-center justify-center rounded-full">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Check/Check-Circle-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-7 w-7 opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="font-serif text-lg font-medium text-brand-navy">
|
||||||
|
{m.enrich_list_empty_heading()}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 max-w-xs font-sans text-sm text-brand-navy/60">
|
||||||
|
{m.enrich_list_empty_body()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Document Rows -->
|
||||||
|
<div class="border-brand-sand border bg-white shadow-sm">
|
||||||
|
<ul class="divide-brand-sand divide-y">
|
||||||
|
{#each documents as doc (doc.id)}
|
||||||
|
<li class="group hover:bg-brand-sand/30 transition-colors duration-200">
|
||||||
|
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
class="font-serif text-lg font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
|
||||||
|
>
|
||||||
|
{doc.title || doc.originalFilename}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 font-sans text-xs text-brand-navy/50">
|
||||||
|
{formatUploadDate(doc.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
109
frontend/src/routes/enrich/[id]/+page.server.ts
Normal file
109
frontend/src/routes/enrich/[id]/+page.server.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage, parseBackendError } from '$lib/errors';
|
||||||
|
|
||||||
|
export async function load({
|
||||||
|
params,
|
||||||
|
fetch,
|
||||||
|
locals
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
locals: App.Locals;
|
||||||
|
}) {
|
||||||
|
const canWrite =
|
||||||
|
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||||
|
g.permissions.includes('WRITE_ALL')
|
||||||
|
) ?? false;
|
||||||
|
if (!canWrite) throw redirect(303, '/');
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
|
const [docResult, countResult] = await Promise.all([
|
||||||
|
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||||
|
api.GET('/api/documents/incomplete-count')
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!docResult.response.ok) {
|
||||||
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
const incompleteCount = countResult.response.ok ? (countResult.data?.count ?? 0) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
document: docResult.data!,
|
||||||
|
incompleteCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redirectToNext(id: string, fetch: typeof globalThis.fetch): Promise<never> {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const nextResult = await api.GET('/api/documents/incomplete/next', {
|
||||||
|
params: { query: { excludeId: id } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextResult.response.ok && nextResult.data) {
|
||||||
|
throw redirect(303, `/enrich/${nextResult.data.id}`);
|
||||||
|
}
|
||||||
|
throw redirect(303, '/enrich/done');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
skip: async ({ params, fetch }: { params: { id: string }; fetch: typeof globalThis.fetch }) => {
|
||||||
|
await redirectToNext(params.id, fetch);
|
||||||
|
},
|
||||||
|
|
||||||
|
save: async ({
|
||||||
|
params,
|
||||||
|
request,
|
||||||
|
fetch
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
request: Request;
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
}) => {
|
||||||
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const backendError = await parseBackendError(res);
|
||||||
|
return { error: getErrorMessage(backendError?.code) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await redirectToNext(params.id, fetch);
|
||||||
|
},
|
||||||
|
|
||||||
|
saveAndReview: async ({
|
||||||
|
params,
|
||||||
|
request,
|
||||||
|
fetch
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
request: Request;
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
}) => {
|
||||||
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
|
const formData = await request.formData();
|
||||||
|
formData.set('metadataComplete', 'true');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const backendError = await parseBackendError(res);
|
||||||
|
return { error: getErrorMessage(backendError?.code) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await redirectToNext(params.id, fetch);
|
||||||
|
}
|
||||||
|
};
|
||||||
162
frontend/src/routes/enrich/[id]/+page.svelte
Normal file
162
frontend/src/routes/enrich/[id]/+page.svelte
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
|
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||||
|
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const doc = $derived(data.document);
|
||||||
|
|
||||||
|
// File preview state
|
||||||
|
let fileUrl = $state('');
|
||||||
|
let fileError = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
// Dummy bindable state required by DocumentViewer
|
||||||
|
let annotateMode = $state(false);
|
||||||
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
|
let activeAnnotationPage = $state<number | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (doc?.id && doc?.filePath) {
|
||||||
|
loadFile(doc.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFile(id: string) {
|
||||||
|
isLoading = true;
|
||||||
|
fileError = '';
|
||||||
|
fileUrl = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/documents/${id}/file`);
|
||||||
|
if (!response.ok) throw new Error('Fehler');
|
||||||
|
const blob = await response.blob();
|
||||||
|
fileUrl = URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
fileError = m.doc_file_error_preview();
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
||||||
|
let senderId = $state(untrack(() => doc.sender?.id ?? ''));
|
||||||
|
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{doc.title || doc.originalFilename || 'Dokument'} — Anreicherung</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex h-[calc(100vh-68px)] flex-col">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="flex items-center justify-between border-b border-line bg-surface px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="/enrich"
|
||||||
|
class="group inline-flex items-center font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
|
/>
|
||||||
|
{m.enrich_back_to_list()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="max-w-sm truncate text-center font-serif text-sm font-medium text-ink">
|
||||||
|
{doc.title || doc.originalFilename}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{m.enrich_progress({ count: data.incompleteCount })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
<!-- Left: PDF preview (60%) -->
|
||||||
|
<div class="relative flex-[6] overflow-hidden border-r border-line">
|
||||||
|
<DocumentViewer
|
||||||
|
doc={doc}
|
||||||
|
fileUrl={fileUrl}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={fileError}
|
||||||
|
bind:annotateMode={annotateMode}
|
||||||
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
|
onAnnotationClick={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: form (40%) -->
|
||||||
|
<div class="flex flex-[4] flex-col overflow-hidden">
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="border-b border-red-200 bg-red-50 px-6 py-3 text-sm text-red-700">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="save-form"
|
||||||
|
method="POST"
|
||||||
|
action="?/save"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance
|
||||||
|
class="flex-1 space-y-5 overflow-y-auto p-6"
|
||||||
|
>
|
||||||
|
<WhoWhenSection
|
||||||
|
bind:senderId={senderId}
|
||||||
|
bind:selectedReceivers={selectedReceivers}
|
||||||
|
initialDateIso={doc.documentDate ?? ''}
|
||||||
|
initialLocation={doc.location ?? ''}
|
||||||
|
initialSenderName={doc.sender
|
||||||
|
? `${doc.sender.firstName} ${doc.sender.lastName}`
|
||||||
|
: ''}
|
||||||
|
/>
|
||||||
|
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Skip form (outside main form to avoid nesting) -->
|
||||||
|
<form id="skip-form" method="POST" action="?/skip" use:enhance></form>
|
||||||
|
|
||||||
|
<!-- Action bar -->
|
||||||
|
<div class="flex items-center justify-between gap-3 border-t border-line bg-surface p-4">
|
||||||
|
<!-- Skip button linked to skip-form -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="skip-form"
|
||||||
|
class="font-sans text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
{m.enrich_skip()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="save-form"
|
||||||
|
formaction="?/save"
|
||||||
|
class="rounded-sm border border-gray-300 px-5 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Save & mark as reviewed -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="save-form"
|
||||||
|
formaction="?/saveAndReview"
|
||||||
|
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||||
|
>
|
||||||
|
{m.btn_save_and_mark_reviewed()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
40
frontend/src/routes/enrich/done/+page.svelte
Normal file
40
frontend/src/routes/enrich/done/+page.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||||
|
<div
|
||||||
|
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border bg-white py-20 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Large-32px/SVG/Action/Check/Check-Circle-LG.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mb-6 h-16 w-16"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 class="font-serif text-2xl font-medium text-brand-navy">
|
||||||
|
{m.enrich_done_heading()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-2 max-w-xs font-sans text-sm text-gray-500">
|
||||||
|
{m.enrich_done_body()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-col items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="bg-brand-navy px-6 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||||
|
>
|
||||||
|
{m.btn_back_to_overview()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/enrich"
|
||||||
|
class="font-sans text-xs text-gray-400 underline-offset-4 transition-colors hover:text-brand-navy hover:underline"
|
||||||
|
>
|
||||||
|
{m.enrich_back_to_list()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -23,6 +23,7 @@ const emptyData = {
|
|||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||||
documents: [],
|
documents: [],
|
||||||
|
incompleteCount: 0,
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user