feat(upload): bulk drag-and-drop upload on home page (#66) #74
@@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.core.io.InputStreamResource;
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -103,6 +106,56 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- DELETE ---
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
||||||
|
documentService.deleteDocument(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QUICK UPLOAD ---
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||||
|
"application/pdf", "image/jpeg", "image/png", "image/tiff");
|
||||||
|
|
||||||
|
public record UploadError(String filename, String code) {}
|
||||||
|
public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}
|
||||||
|
|
||||||
|
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public QuickUploadResult quickUpload(
|
||||||
|
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
|
||||||
|
List<Document> created = new ArrayList<>();
|
||||||
|
List<Document> updated = new ArrayList<>();
|
||||||
|
List<UploadError> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
if (files == null || files.isEmpty()) {
|
||||||
|
return new QuickUploadResult(created, updated, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
|
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocument(file);
|
||||||
|
if (result.isNew()) {
|
||||||
|
created.add(result.document());
|
||||||
|
} else {
|
||||||
|
updated.add(result.document());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
errors.add(new UploadError(file.getOriginalFilename(), "FILE_UPLOAD_FAILED"));
|
||||||
|
log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QuickUploadResult(created, updated, errors);
|
||||||
|
}
|
||||||
|
|
||||||
@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,6 +17,8 @@ public enum ErrorCode {
|
|||||||
FILE_NOT_FOUND,
|
FILE_NOT_FOUND,
|
||||||
/** An error occurred while uploading a file to object storage. 500 */
|
/** An error occurred while uploading a file to object storage. 500 */
|
||||||
FILE_UPLOAD_FAILED,
|
FILE_UPLOAD_FAILED,
|
||||||
|
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
|
||||||
|
UNSUPPORTED_FILE_TYPE,
|
||||||
|
|
||||||
// --- Users ---
|
// --- Users ---
|
||||||
/** A user with the given ID or username does not exist. 404 */
|
/** A user with the given ID or username does not exist. 404 */
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
|
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
||||||
|
Optional<Document> findFirstByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
// Findet alle Dokumente mit einem bestimmten Status
|
// Findet alle Dokumente mit einem bestimmten Status
|
||||||
// z.B. um alle offenen "PLACEHOLDER" zu finden
|
// z.B. um alle offenen "PLACEHOLDER" zu finden
|
||||||
List<Document> findByStatus(DocumentStatus status);
|
List<Document> findByStatus(DocumentStatus status);
|
||||||
|
|||||||
@@ -42,18 +42,21 @@ public class DocumentService {
|
|||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final AnnotationService annotationService;
|
private final AnnotationService annotationService;
|
||||||
|
|
||||||
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Datei hoch.
|
* Lädt eine Datei hoch.
|
||||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||||
* - Wenn JA: Aktualisiert Status und verknüpft Datei.
|
* - Wenn JA: Aktualisiert Status und verknüpft Datei — isNew = false.
|
||||||
* - Wenn NEIN: Erstellt neuen Eintrag (wartet auf Metadaten).
|
* - Wenn NEIN: Erstellt neuen Eintrag — isNew = true.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Document storeDocument(MultipartFile file) throws IOException {
|
public StoreResult storeDocument(MultipartFile file) throws IOException {
|
||||||
String originalFilename = file.getOriginalFilename();
|
String originalFilename = file.getOriginalFilename();
|
||||||
|
|
||||||
// 1. Check for existing record
|
// 1. Check for existing record (findFirst to survive duplicate filenames in the DB)
|
||||||
Optional<Document> existingDoc = documentRepository.findByOriginalFilename(originalFilename);
|
Optional<Document> existingDoc = documentRepository.findFirstByOriginalFilename(originalFilename);
|
||||||
|
boolean isNew = existingDoc.isEmpty();
|
||||||
Document document;
|
Document document;
|
||||||
|
|
||||||
if (existingDoc.isPresent()) {
|
if (existingDoc.isPresent()) {
|
||||||
@@ -61,7 +64,7 @@ public class DocumentService {
|
|||||||
} else {
|
} else {
|
||||||
document = Document.builder()
|
document = Document.builder()
|
||||||
.originalFilename(originalFilename)
|
.originalFilename(originalFilename)
|
||||||
.title(originalFilename)
|
.title(stripExtension(originalFilename))
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@@ -77,7 +80,7 @@ public class DocumentService {
|
|||||||
document.setStatus(DocumentStatus.UPLOADED);
|
document.setStatus(DocumentStatus.UPLOADED);
|
||||||
}
|
}
|
||||||
|
|
||||||
return documentRepository.save(document);
|
return new StoreResult(documentRepository.save(document), isNew);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -234,8 +237,8 @@ public class DocumentService {
|
|||||||
.and(hasReceiver(receiver))
|
.and(hasReceiver(receiver))
|
||||||
.and(hasTags(tags));
|
.and(hasTags(tags));
|
||||||
|
|
||||||
// Immer sortiert nach Datum
|
// Neueste zuerst (nach Erstellungsdatum)
|
||||||
return documentRepository.findAll(spec, Sort.by(Sort.Direction.ASC, "documentDate"));
|
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SPEZIALITÄT: Der Schriftwechsel
|
// 2. SPEZIALITÄT: Der Schriftwechsel
|
||||||
@@ -277,6 +280,14 @@ public class DocumentService {
|
|||||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteDocument(UUID id) {
|
||||||
|
if (!documentRepository.existsById(id)) {
|
||||||
|
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||||
|
}
|
||||||
|
documentRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteTagCascading(UUID tagId) {
|
public void deleteTagCascading(UUID tagId) {
|
||||||
documentRepository.findByTags_Id(tagId).forEach(doc -> {
|
documentRepository.findByTags_Id(tagId).forEach(doc -> {
|
||||||
@@ -307,6 +318,12 @@ public class DocumentService {
|
|||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String stripExtension(String filename) {
|
||||||
|
if (filename == null) return null;
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot > 0 ? filename.substring(0, dot) : filename;
|
||||||
|
}
|
||||||
|
|
||||||
private static String sha256Hex(byte[] bytes) {
|
private static String sha256Hex(byte[] bytes) {
|
||||||
try {
|
try {
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Add ON DELETE CASCADE to document_tags and document_receivers so that
|
||||||
|
-- deleting a document automatically removes its tag and receiver associations.
|
||||||
|
|
||||||
|
ALTER TABLE public.document_tags
|
||||||
|
DROP CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2,
|
||||||
|
ADD CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2
|
||||||
|
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE public.document_receivers
|
||||||
|
DROP CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm,
|
||||||
|
ADD CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm
|
||||||
|
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;
|
||||||
@@ -121,6 +121,97 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
|
.delete("/api/documents/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
|
.delete("/api/documents/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
|
.delete("/api/documents/" + id))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/quick-upload ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||||
|
when(documentService.storeDocument(any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||||
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_placesDocumentInUpdated_whenFilenameAlreadyExists() throws Exception {
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("Alter Brief").originalFilename("scan001.pdf").build();
|
||||||
|
when(documentService.storeDocument(any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
||||||
|
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -35,6 +35,29 @@ class DocumentServiceTest {
|
|||||||
@Mock AnnotationService annotationService;
|
@Mock AnnotationService annotationService;
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
|
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteDocument_deletesById_whenExists() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(documentRepository.existsById(id)).thenReturn(true);
|
||||||
|
|
||||||
|
documentService.deleteDocument(id);
|
||||||
|
|
||||||
|
verify(documentRepository).deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteDocument_throwsNotFound_whenMissing() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(documentRepository.existsById(id)).thenReturn(false);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> documentService.deleteDocument(id))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining(id.toString());
|
||||||
|
verify(documentRepository, never()).deleteById(any());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── getDocumentById ──────────────────────────────────────────────────────
|
// ─── getDocumentById ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -212,6 +235,75 @@ class DocumentServiceTest {
|
|||||||
verify(documentVersionService).recordVersion(any(Document.class));
|
verify(documentVersionService).recordVersion(any(Document.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── storeDocument ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_setsTitle_withoutFileExtension_forNewDocument() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||||
|
|
||||||
|
when(documentRepository.findFirstByOriginalFilename("scan001.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
verify(documentRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getTitle()).isEqualTo("scan001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_preservesExistingTitle_whenPlaceholderAlreadyExists() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||||
|
Document placeholder = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("Brief an Oma").originalFilename("scan001.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
|
||||||
|
when(documentRepository.findFirstByOriginalFilename("scan001.pdf")).thenReturn(Optional.of(placeholder));
|
||||||
|
when(documentRepository.save(any())).thenReturn(placeholder);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
assertThat(placeholder.getTitle()).isEqualTo("Brief an Oma");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_marksResultAsNew_whenNoExistingDocument() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("new.pdf").build();
|
||||||
|
|
||||||
|
when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
|
||||||
|
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocument(file);
|
||||||
|
|
||||||
|
assertThat(result.isNew()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_marksResultAsNotNew_whenDocumentWithSameFilenameExists() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "existing.pdf", "application/pdf", new byte[]{1});
|
||||||
|
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("existing.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).build();
|
||||||
|
|
||||||
|
when(documentRepository.findFirstByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
when(documentRepository.save(any())).thenReturn(existing);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/existing.pdf", "hash"));
|
||||||
|
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocument(file);
|
||||||
|
|
||||||
|
assertThat(result.isNew()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||||
"error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.",
|
"error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.",
|
||||||
|
"error_unsupported_file_type": "Dieses Dateiformat wird nicht unterstützt.",
|
||||||
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
|
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
|
||||||
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
|
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
|
||||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"btn_edit": "Bearbeiten",
|
"btn_edit": "Bearbeiten",
|
||||||
"btn_create": "Erstellen",
|
"btn_create": "Erstellen",
|
||||||
"btn_delete": "Löschen",
|
"btn_delete": "Löschen",
|
||||||
|
"doc_delete_confirm": "Dokument wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||||
"btn_back": "Zurück",
|
"btn_back": "Zurück",
|
||||||
"btn_back_to_document": "Zurück zum Dokument",
|
"btn_back_to_document": "Zurück zum Dokument",
|
||||||
@@ -265,5 +267,12 @@
|
|||||||
"doc_panel_annotation_thread_title": "Annotation",
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||||
"pdf_annotations_hide": "Annotierungen verbergen"
|
"pdf_annotations_hide": "Annotierungen verbergen",
|
||||||
|
"upload_drop_hint": "Dateien ablegen oder auswählen",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} Dokument(e) erstellt",
|
||||||
|
"upload_duplicate": "{filename} existiert bereits —",
|
||||||
|
"upload_duplicate_link": "Zum Dokument",
|
||||||
|
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
||||||
|
"upload_error": "Fehler beim Hochladen von {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error_document_no_file": "No file is associated with this document.",
|
"error_document_no_file": "No file is associated with this document.",
|
||||||
"error_file_not_found": "The file could not be found in storage.",
|
"error_file_not_found": "The file could not be found in storage.",
|
||||||
"error_file_upload_failed": "The file could not be uploaded.",
|
"error_file_upload_failed": "The file could not be uploaded.",
|
||||||
|
"error_unsupported_file_type": "This file format is not supported.",
|
||||||
"error_user_not_found": "User not found.",
|
"error_user_not_found": "User not found.",
|
||||||
"error_import_already_running": "An import is already running. Please wait for it to finish.",
|
"error_import_already_running": "An import is already running. Please wait for it to finish.",
|
||||||
"error_unauthorized": "You are not logged in.",
|
"error_unauthorized": "You are not logged in.",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"btn_edit": "Edit",
|
"btn_edit": "Edit",
|
||||||
"btn_create": "Create",
|
"btn_create": "Create",
|
||||||
"btn_delete": "Delete",
|
"btn_delete": "Delete",
|
||||||
|
"doc_delete_confirm": "Really delete this document? This action cannot be undone.",
|
||||||
"btn_back_to_overview": "Back to overview",
|
"btn_back_to_overview": "Back to overview",
|
||||||
"btn_back": "Back",
|
"btn_back": "Back",
|
||||||
"btn_back_to_document": "Back to document",
|
"btn_back_to_document": "Back to document",
|
||||||
@@ -265,5 +267,12 @@
|
|||||||
"doc_panel_annotation_thread_title": "Annotation",
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||||
"pdf_annotations_show": "Show annotations",
|
"pdf_annotations_show": "Show annotations",
|
||||||
"pdf_annotations_hide": "Hide annotations"
|
"pdf_annotations_hide": "Hide annotations",
|
||||||
|
"upload_drop_hint": "Drop files or click to select",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} document(s) created",
|
||||||
|
"upload_duplicate": "{filename} already exists —",
|
||||||
|
"upload_duplicate_link": "View document",
|
||||||
|
"upload_invalid_type": "{filename}: unsupported file format",
|
||||||
|
"upload_error": "Error uploading {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||||
"error_file_upload_failed": "No se pudo subir el archivo.",
|
"error_file_upload_failed": "No se pudo subir el archivo.",
|
||||||
|
"error_unsupported_file_type": "Este formato de archivo no está admitido.",
|
||||||
"error_user_not_found": "Usuario no encontrado.",
|
"error_user_not_found": "Usuario no encontrado.",
|
||||||
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
|
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
|
||||||
"error_unauthorized": "No ha iniciado sesión.",
|
"error_unauthorized": "No ha iniciado sesión.",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"btn_edit": "Editar",
|
"btn_edit": "Editar",
|
||||||
"btn_create": "Crear",
|
"btn_create": "Crear",
|
||||||
"btn_delete": "Eliminar",
|
"btn_delete": "Eliminar",
|
||||||
|
"doc_delete_confirm": "¿Realmente eliminar este documento? Esta acción no se puede deshacer.",
|
||||||
"btn_back_to_overview": "Volver al resumen",
|
"btn_back_to_overview": "Volver al resumen",
|
||||||
"btn_back": "Volver",
|
"btn_back": "Volver",
|
||||||
"btn_back_to_document": "Volver al documento",
|
"btn_back_to_document": "Volver al documento",
|
||||||
@@ -265,5 +267,12 @@
|
|||||||
"doc_panel_annotation_thread_title": "Anotación",
|
"doc_panel_annotation_thread_title": "Anotación",
|
||||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||||
"pdf_annotations_show": "Mostrar anotaciones",
|
"pdf_annotations_show": "Mostrar anotaciones",
|
||||||
"pdf_annotations_hide": "Ocultar anotaciones"
|
"pdf_annotations_hide": "Ocultar anotaciones",
|
||||||
|
"upload_drop_hint": "Soltar archivos o hacer clic para seleccionar",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} documento(s) creado(s)",
|
||||||
|
"upload_duplicate": "{filename} ya existe —",
|
||||||
|
"upload_duplicate_link": "Ver documento",
|
||||||
|
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
||||||
|
"upload_error": "Error al subir {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ErrorCode =
|
|||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
| 'FILE_UPLOAD_FAILED'
|
| 'FILE_UPLOAD_FAILED'
|
||||||
|
| 'UNSUPPORTED_FILE_TYPE'
|
||||||
| 'USER_NOT_FOUND'
|
| 'USER_NOT_FOUND'
|
||||||
| 'EMAIL_ALREADY_IN_USE'
|
| 'EMAIL_ALREADY_IN_USE'
|
||||||
| 'WRONG_CURRENT_PASSWORD'
|
| 'WRONG_CURRENT_PASSWORD'
|
||||||
@@ -54,6 +55,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_file_not_found();
|
return m.error_file_not_found();
|
||||||
case 'FILE_UPLOAD_FAILED':
|
case 'FILE_UPLOAD_FAILED':
|
||||||
return m.error_file_upload_failed();
|
return m.error_file_upload_failed();
|
||||||
|
case 'UNSUPPORTED_FILE_TYPE':
|
||||||
|
return m.error_unsupported_file_type();
|
||||||
case 'USER_NOT_FOUND':
|
case 'USER_NOT_FOUND':
|
||||||
return m.error_user_not_found();
|
return m.error_user_not_found();
|
||||||
case 'EMAIL_ALREADY_IN_USE':
|
case 'EMAIL_ALREADY_IN_USE':
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -18,6 +19,16 @@ let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
|||||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
||||||
|
|
||||||
|
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let windowDragging = $state(false);
|
||||||
|
let dragCounter = 0;
|
||||||
|
let isUploading = $state(false);
|
||||||
|
let uploadProgress = $state(0);
|
||||||
|
let uploadMessages = $state<{ text: string; isError: boolean; link?: string }[]>([]);
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||||
@@ -29,6 +40,101 @@ const hasAdvancedFilters = (filters: typeof data.filters) =>
|
|||||||
|
|
||||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
windowDragging = false;
|
||||||
|
dragCounter = 0;
|
||||||
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
|
await uploadFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
input.value = '';
|
||||||
|
await uploadFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(files: File[]) {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
const messages: { text: string; isError: boolean; link?: string }[] = [];
|
||||||
|
|
||||||
|
// Client-side type validation
|
||||||
|
const valid: File[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||||
|
messages.push({ text: m.upload_invalid_type({ filename: file.name }), isError: true });
|
||||||
|
} else {
|
||||||
|
valid.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid.length === 0) {
|
||||||
|
uploadMessages = messages;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
uploadProgress = 0;
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of valid) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ok, body } = await new Promise<{ ok: boolean; body: string }>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/documents/quick-upload');
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) uploadProgress = Math.round((e.loaded / e.total) * 100);
|
||||||
|
});
|
||||||
|
xhr.addEventListener('load', () => resolve({ ok: xhr.status < 300, body: xhr.responseText }));
|
||||||
|
xhr.addEventListener('error', () => reject(new Error('Network error')));
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
const result = JSON.parse(body);
|
||||||
|
if (result.created?.length > 0) {
|
||||||
|
messages.push({ text: m.upload_success({ count: result.created.length }), isError: false });
|
||||||
|
}
|
||||||
|
for (const doc of result.updated ?? []) {
|
||||||
|
messages.push({
|
||||||
|
text: m.upload_duplicate({ filename: doc.originalFilename }),
|
||||||
|
isError: false,
|
||||||
|
link: `/documents/${doc.id}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const err of result.errors ?? []) {
|
||||||
|
messages.push({
|
||||||
|
text: `${err.filename}: ${getErrorMessage(err.code)}`,
|
||||||
|
isError: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await invalidateAll();
|
||||||
|
} else {
|
||||||
|
for (const file of valid) {
|
||||||
|
messages.push({ text: m.upload_error({ filename: file.name }), isError: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUploading = false;
|
||||||
|
uploadProgress = 0;
|
||||||
|
uploadMessages = messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerSearch() {
|
function triggerSearch() {
|
||||||
const params = new SvelteURLSearchParams();
|
const params = new SvelteURLSearchParams();
|
||||||
|
|
||||||
@@ -62,6 +168,40 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expand drop zone whenever a file is dragged anywhere over the browser window
|
||||||
|
$effect(() => {
|
||||||
|
if (!data.canWrite) return;
|
||||||
|
|
||||||
|
function onWindowDragEnter(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||||
|
dragCounter++;
|
||||||
|
windowDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowDragLeave() {
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragCounter = 0;
|
||||||
|
windowDragging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowDrop() {
|
||||||
|
dragCounter = 0;
|
||||||
|
windowDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('dragenter', onWindowDragEnter);
|
||||||
|
window.addEventListener('dragleave', onWindowDragLeave);
|
||||||
|
window.addEventListener('drop', onWindowDrop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('dragenter', onWindowDragEnter);
|
||||||
|
window.removeEventListener('dragleave', onWindowDragLeave);
|
||||||
|
window.removeEventListener('drop', onWindowDrop);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Sync local state with server data after navigation.
|
// Sync local state with server data after navigation.
|
||||||
// Guard q: skip overwrite while the user is actively typing in the search field.
|
// Guard q: skip overwrite while the user is actively typing in the search field.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -210,6 +350,74 @@ $effect(() => {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if data.canWrite}
|
||||||
|
<!-- UPLOAD DROP ZONE -->
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="mb-4 flex cursor-pointer items-center justify-center gap-3 border border-dashed px-6 text-sm transition-all duration-200 {isDragging
|
||||||
|
? 'border-primary bg-accent-bg py-10 text-primary'
|
||||||
|
: windowDragging
|
||||||
|
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80'
|
||||||
|
: 'border-ink/20 py-3 text-ink-3 hover:border-primary hover:text-primary'}"
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
onclick={() => fileInput.click()}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4 shrink-0 opacity-50"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
{#if isUploading}
|
||||||
|
<div class="flex w-48 flex-col items-center gap-1">
|
||||||
|
<div class="h-1.5 w-full overflow-hidden rounded-full bg-ink/10">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-primary transition-all duration-200"
|
||||||
|
style="width: {uploadProgress}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="font-sans font-medium">{m.upload_drop_hint()}</span>
|
||||||
|
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if uploadMessages.length > 0}
|
||||||
|
<div class="mb-4 flex flex-col gap-1">
|
||||||
|
{#each uploadMessages as msg, i (i)}
|
||||||
|
<p
|
||||||
|
class="font-sans text-sm {msg.isError
|
||||||
|
? 'text-red-600'
|
||||||
|
: msg.link
|
||||||
|
? 'text-amber-700'
|
||||||
|
: 'text-green-700'}"
|
||||||
|
>
|
||||||
|
{msg.text}
|
||||||
|
{#if msg.link}
|
||||||
|
<a href={msg.link} class="underline hover:no-underline">{m.upload_duplicate_link()}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- DOCUMENT LIST HEADER -->
|
<!-- DOCUMENT LIST HEADER -->
|
||||||
<div class="mb-2 flex justify-end">
|
<div class="mb-2 flex justify-end">
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
@@ -360,4 +568,12 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
|
||||||
|
class="sr-only"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export async function load({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, params, fetch }) => {
|
update: async ({ request, params, fetch }) => {
|
||||||
// Raw fetch is used here because FormData multipart bodies are passed through
|
// Raw fetch is used here because FormData multipart bodies are passed through
|
||||||
// directly from the browser without transformation.
|
// directly from the browser without transformation.
|
||||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
@@ -58,5 +58,20 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/documents/${params.id}`);
|
throw redirect(303, `/documents/${params.id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ params, fetch }) => {
|
||||||
|
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const backendError = await parseBackendError(res);
|
||||||
|
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw redirect(303, '/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ let selectedReceivers = $state(doc.receivers ?? []);
|
|||||||
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
|
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
|
||||||
let dateIso = $state(doc.documentDate ?? '');
|
let dateIso = $state(doc.documentDate ?? '');
|
||||||
let dateDirty = $state(false);
|
let dateDirty = $state(false);
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
|
||||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||||
|
|
||||||
@@ -63,7 +64,13 @@ function handleDateInput(e: Event) {
|
|||||||
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
|
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance
|
||||||
|
class="space-y-6 pb-20"
|
||||||
|
>
|
||||||
<!-- ── Section 1: Wer & Wann ── -->
|
<!-- ── Section 1: Wer & Wann ── -->
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<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">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
@@ -244,18 +251,68 @@ function handleDateInput(e: Event) {
|
|||||||
<div
|
<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)]"
|
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
|
<!-- Left: delete -->
|
||||||
href="/documents/{doc.id}"
|
<div class="flex items-center gap-3">
|
||||||
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
{#if confirmDelete}
|
||||||
>
|
<span class="font-sans text-sm text-red-700">{m.doc_delete_confirm()}</span>
|
||||||
{m.btn_cancel()}
|
<button
|
||||||
</a>
|
type="submit"
|
||||||
<button
|
form="delete-form"
|
||||||
type="submit"
|
class="rounded bg-red-600 px-4 py-1.5 text-sm font-bold text-white transition-colors hover:bg-red-700"
|
||||||
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
>
|
||||||
>
|
{m.btn_delete()}
|
||||||
{m.btn_save()}
|
</button>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmDelete = false)}
|
||||||
|
class="text-sm text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmDelete = true)}
|
||||||
|
class="flex items-center gap-1.5 rounded border border-red-300 px-4 py-1.5 text-sm font-bold text-red-600 transition-colors hover:border-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: cancel + save -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="/documents/{doc.id}"
|
||||||
|
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form id="delete-form" method="POST" action="?/delete" use:enhance></form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Page from './+page.svelte';
|
|||||||
|
|
||||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||||
|
|
||||||
// Silence fetch calls from PersonTypeahead when advanced filters are open
|
// Silence fetch calls from PersonTypeahead when advanced filters are open
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
|
|||||||
Reference in New Issue
Block a user