Compare commits
9 Commits
4ccc8d69d0
...
ba04e62f87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba04e62f87 | ||
|
|
fa4bfb8e5c | ||
|
|
fde75f3fcf | ||
|
|
03a1a86cdb | ||
|
|
55ffaa1c5c | ||
|
|
1fdde95b09 | ||
|
|
c056d804e6 | ||
|
|
490382b5de | ||
|
|
557b62ac5c |
@@ -38,6 +38,11 @@ public class PersonController {
|
|||||||
return documentService.getDocumentsBySender(id);
|
return documentService.getDocumentsBySender(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/received-documents")
|
||||||
|
public List<Document> getPersonReceivedDocuments(@PathVariable UUID id) {
|
||||||
|
return documentService.getDocumentsByReceiver(id);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
|
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
|
||||||
String firstName = body.get("firstName");
|
String firstName = body.get("firstName");
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
List<Document> findBySenderId(UUID senderId);
|
List<Document> findBySenderId(UUID senderId);
|
||||||
|
|
||||||
|
List<Document> findByReceiversId(UUID receiverId);
|
||||||
|
|
||||||
List<Document> findByTags_Id(UUID tagId);
|
List<Document> findByTags_Id(UUID tagId);
|
||||||
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
|
|||||||
@@ -254,6 +254,10 @@ public class DocumentService {
|
|||||||
return documentRepository.findBySenderId(senderId);
|
return documentRepository.findBySenderId(senderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Document> getDocumentsByReceiver(UUID receiverId) {
|
||||||
|
return documentRepository.findByReceiversId(receiverId);
|
||||||
|
}
|
||||||
|
|
||||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
||||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
||||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(PersonController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class PersonControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean PersonService personService;
|
||||||
|
@MockitoBean DocumentService documentService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
// ─── GET /api/persons/{id}/received-documents ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getReceivedDocuments_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/received-documents", UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getReceivedDocuments_returns200_whenAuthenticated() throws Exception {
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
when(documentService.getDocumentsByReceiver(personId)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,4 +121,15 @@ class DocumentServiceTest {
|
|||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(documentRepository).save(any());
|
verify(documentRepository).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── getDocumentsByReceiver ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDocumentsByReceiver_returnsDocumentsWherePersonIsReceiver() {
|
||||||
|
UUID receiverId = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
||||||
|
when(documentRepository.findByReceiversId(receiverId)).thenReturn(List.of(doc));
|
||||||
|
|
||||||
|
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
frontend/e2e/permissions.spec.ts
Normal file
31
frontend/e2e/permissions.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Write permissions — admin user', () => {
|
||||||
|
test('admin user sees Neues Dokument link on home page', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin user sees Neue Person link on persons page', async ({ page }) => {
|
||||||
|
await page.goto('/persons');
|
||||||
|
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin user can navigate to /persons/new', async ({ page }) => {
|
||||||
|
await page.goto('/persons/new');
|
||||||
|
await expect(page).toHaveURL('/persons/new');
|
||||||
|
await expect(page.getByLabel('Vorname')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin user can navigate to /documents/new', async ({ page }) => {
|
||||||
|
await page.goto('/documents/new');
|
||||||
|
await expect(page).toHaveURL('/documents/new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin user sees edit button on person detail page', async ({ page }) => {
|
||||||
|
await page.goto('/persons');
|
||||||
|
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||||
|
await firstPerson.click();
|
||||||
|
await expect(page.getByRole('button', { name: /Bearbeiten/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -114,6 +114,19 @@ test.describe('Person detail — sort toggle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Person detail — sent and received documents', () => {
|
||||||
|
test('shows both sent and received document sections', async ({ page }) => {
|
||||||
|
await page.goto('/persons');
|
||||||
|
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||||
|
await firstPerson.click();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Gesendete Dokumente/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: /Empfangene Dokumente/i })).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-sent-received.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('Person detail — conversations link', () => {
|
test.describe('Person detail — conversations link', () => {
|
||||||
test('has a conversations link that pre-fills the person', async ({ page }) => {
|
test('has a conversations link that pre-fills the person', async ({ page }) => {
|
||||||
await page.goto('/persons');
|
await page.goto('/persons');
|
||||||
|
|||||||
@@ -120,6 +120,11 @@
|
|||||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||||
"person_docs_heading": "Gesendete Dokumente",
|
"person_docs_heading": "Gesendete Dokumente",
|
||||||
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
||||||
|
"person_received_docs_heading": "Empfangene Dokumente",
|
||||||
|
"person_no_received_docs": "Diese Person ist noch nicht als Empfänger verknüpft.",
|
||||||
|
"person_role_sender": "Gesendet",
|
||||||
|
"person_role_receiver": "Empfangen",
|
||||||
|
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
||||||
|
|
||||||
"conv_heading": "Konversationen",
|
"conv_heading": "Konversationen",
|
||||||
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
|
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
|
||||||
|
|||||||
@@ -120,6 +120,11 @@
|
|||||||
"person_years_error_order": "Birth year must be before death year",
|
"person_years_error_order": "Birth year must be before death year",
|
||||||
"person_docs_heading": "Sent documents",
|
"person_docs_heading": "Sent documents",
|
||||||
"person_no_docs": "This person has not yet been linked as a sender.",
|
"person_no_docs": "This person has not yet been linked as a sender.",
|
||||||
|
"person_received_docs_heading": "Received documents",
|
||||||
|
"person_no_received_docs": "This person has not yet been linked as a receiver.",
|
||||||
|
"person_role_sender": "Sent",
|
||||||
|
"person_role_receiver": "Received",
|
||||||
|
"person_co_correspondents_heading": "Frequent correspondents",
|
||||||
|
|
||||||
"conv_heading": "Conversations",
|
"conv_heading": "Conversations",
|
||||||
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
|
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
|
||||||
|
|||||||
@@ -120,6 +120,11 @@
|
|||||||
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
||||||
"person_docs_heading": "Documentos enviados",
|
"person_docs_heading": "Documentos enviados",
|
||||||
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
||||||
|
"person_received_docs_heading": "Documentos recibidos",
|
||||||
|
"person_no_received_docs": "Esta persona aún no está vinculada como receptor.",
|
||||||
|
"person_role_sender": "Enviado",
|
||||||
|
"person_role_receiver": "Recibido",
|
||||||
|
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||||
|
|
||||||
"conv_heading": "Conversaciones",
|
"conv_heading": "Conversaciones",
|
||||||
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
|
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
|
||||||
|
|||||||
@@ -196,6 +196,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/persons/{id}/received-documents": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getPersonReceivedDocuments"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/persons/{id}/documents": {
|
"/api/persons/{id}/documents": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -308,7 +324,9 @@ export interface components {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
/** Format: int32 */
|
||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
};
|
};
|
||||||
DocumentUpdateDTO: {
|
DocumentUpdateDTO: {
|
||||||
@@ -834,6 +852,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getPersonReceivedDocuments: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Document"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getPersonDocuments: {
|
getPersonDocuments: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LayoutServerLoad } from './$types';
|
|||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
return {
|
return {
|
||||||
user: locals.user
|
user: locals.user,
|
||||||
|
canWrite: locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -191,6 +191,7 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- DOCUMENT LIST HEADER -->
|
<!-- DOCUMENT LIST HEADER -->
|
||||||
<div class="mb-2 flex justify-end">
|
<div class="mb-2 flex justify-end">
|
||||||
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/documents/new"
|
href="/documents/new"
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||||
@@ -198,6 +199,7 @@ $effect(() => {
|
|||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" alt="" aria-hidden="true" class="h-4 w-4" />
|
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" alt="" aria-hidden="true" class="h-4 w-4" />
|
||||||
{m.docs_btn_new()}
|
{m.docs_btn_new()}
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DOCUMENT LIST -->
|
<!-- DOCUMENT LIST -->
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 flex-shrink-0 ml-4 font-sans">
|
<div class="flex items-center gap-3 flex-shrink-0 ml-4 font-sans">
|
||||||
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}/edit"
|
href="/documents/{doc.id}/edit"
|
||||||
class="text-brand-navy bg-transparent border border-brand-navy hover:bg-brand-navy hover:text-white px-4 py-2 rounded text-sm font-medium transition flex items-center gap-2"
|
class="text-brand-navy bg-transparent border border-brand-navy hover:bg-brand-navy hover:text-white px-4 py-2 rounded text-sm font-medium transition flex items-center gap-2"
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg" alt="" aria-hidden="true" class="w-4 h-4" />
|
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg" alt="" aria-hidden="true" class="w-4 h-4" />
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if doc.filePath}
|
{#if doc.filePath}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { env } from '$env/dynamic/private';
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
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 error(403, 'Forbidden');
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
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 error(403, 'Forbidden');
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const personsResult = await api.GET('/api/persons');
|
const personsResult = await api.GET('/api/persons');
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ vi.stubGlobal(
|
|||||||
|
|
||||||
const emptyData = {
|
const emptyData = {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
|
canWrite: true,
|
||||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||||
documents: [],
|
documents: [],
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function handleSearch(e: Event) {
|
|||||||
<p class="mt-2 max-w-xl font-sans text-sm text-brand-navy/60">
|
<p class="mt-2 max-w-xl font-sans text-sm text-brand-navy/60">
|
||||||
{m.persons_subtitle()}
|
{m.persons_subtitle()}
|
||||||
</p>
|
</p>
|
||||||
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/persons/new"
|
href="/persons/new"
|
||||||
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||||
@@ -32,6 +33,7 @@ function handleSearch(e: Event) {
|
|||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" alt="" aria-hidden="true" class="h-4 w-4" />
|
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" alt="" aria-hidden="true" class="h-4 w-4" />
|
||||||
{m.persons_btn_new()}
|
{m.persons_btn_new()}
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ export async function load({ params, fetch }) {
|
|||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const [personResult, docsResult] = await Promise.all([
|
const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } })
|
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
||||||
|
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!personResult.response.ok) {
|
if (!personResult.response.ok) {
|
||||||
@@ -18,7 +19,8 @@ export async function load({ params, fetch }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
person: personResult.data!,
|
person: personResult.data!,
|
||||||
documents: docsResult.data ?? []
|
sentDocuments: sentDocsResult.data ?? [],
|
||||||
|
receivedDocuments: receivedDocsResult.data ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,56 @@
|
|||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const person = $derived(data.person);
|
const person = $derived(data.person);
|
||||||
const documents = $derived(data.documents);
|
const sentDocuments = $derived(data.sentDocuments);
|
||||||
|
const receivedDocuments = $derived(data.receivedDocuments);
|
||||||
|
|
||||||
|
const DOCS_PREVIEW_LIMIT = 5;
|
||||||
|
|
||||||
let sortDir = $state<SortDir>('DESC');
|
let sortDir = $state<SortDir>('DESC');
|
||||||
const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
|
let showAllSent = $state(false);
|
||||||
|
let showAllReceived = $state(false);
|
||||||
|
|
||||||
|
const sortedSentDocuments = $derived(sortDocumentsByDate(sentDocuments, sortDir));
|
||||||
|
const sortedReceivedDocuments = $derived(sortDocumentsByDate(receivedDocuments, sortDir));
|
||||||
|
|
||||||
|
const visibleSentDocuments = $derived(showAllSent ? sortedSentDocuments : sortedSentDocuments.slice(0, DOCS_PREVIEW_LIMIT));
|
||||||
|
const visibleReceivedDocuments = $derived(showAllReceived ? sortedReceivedDocuments : sortedReceivedDocuments.slice(0, DOCS_PREVIEW_LIMIT));
|
||||||
|
|
||||||
|
const allDocuments = $derived([...sentDocuments, ...receivedDocuments]);
|
||||||
|
|
||||||
|
const docStats = $derived(() => {
|
||||||
|
const dated = allDocuments.filter(d => d.documentDate);
|
||||||
|
const years = dated.map(d => parseInt(d.documentDate!.substring(0, 4)));
|
||||||
|
return {
|
||||||
|
total: allDocuments.length,
|
||||||
|
minYear: years.length ? Math.min(...years) : null,
|
||||||
|
maxYear: years.length ? Math.max(...years) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const coCorrespondents = $derived(() => {
|
||||||
|
const freq = new Map<string, { id: string; name: string; count: number }>();
|
||||||
|
|
||||||
|
for (const doc of sentDocuments) {
|
||||||
|
for (const receiver of doc.receivers ?? []) {
|
||||||
|
const key = receiver.id;
|
||||||
|
const existing = freq.get(key);
|
||||||
|
if (existing) existing.count++;
|
||||||
|
else freq.set(key, { id: receiver.id, name: `${receiver.firstName} ${receiver.lastName}`, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const doc of receivedDocuments) {
|
||||||
|
if (doc.sender && doc.sender.id !== person.id) {
|
||||||
|
const key = doc.sender.id;
|
||||||
|
const existing = freq.get(key);
|
||||||
|
if (existing) existing.count++;
|
||||||
|
else freq.set(key, { id: doc.sender.id, name: `${doc.sender.firstName} ${doc.sender.lastName}`, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...freq.values()].sort((a, b) => b.count - a.count).slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
let editMode = $state(false);
|
let editMode = $state(false);
|
||||||
let mergeTargetId = $state('');
|
let mergeTargetId = $state('');
|
||||||
@@ -43,7 +89,7 @@
|
|||||||
<div class="h-2 bg-brand-navy w-full"></div>
|
<div class="h-2 bg-brand-navy w-full"></div>
|
||||||
|
|
||||||
<div class="p-8 md:p-10">
|
<div class="p-8 md:p-10">
|
||||||
{#if editMode}
|
{#if editMode && data.canWrite}
|
||||||
<!-- Edit Form -->
|
<!-- Edit Form -->
|
||||||
<form method="POST" action="?/update" use:enhance>
|
<form method="POST" action="?/update" use:enhance>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
@@ -153,10 +199,12 @@
|
|||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||||
{m.person_btn_conversations()}
|
{m.person_btn_conversations()}
|
||||||
</a>
|
</a>
|
||||||
|
{#if data.canWrite}
|
||||||
<button onclick={() => (editMode = true)} class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
|
<button onclick={() => (editMode = true)} class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
|
||||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg" alt="" aria-hidden="true" class="w-3.5 h-3.5" />
|
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg" alt="" aria-hidden="true" class="w-3.5 h-3.5" />
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -198,6 +246,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Merge Section -->
|
<!-- Merge Section -->
|
||||||
|
{#if data.canWrite}
|
||||||
{#key person.id}
|
{#key person.id}
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm overflow-hidden mb-10">
|
<div class="bg-white shadow-sm border border-brand-sand rounded-sm overflow-hidden mb-10">
|
||||||
<div class="p-6 md:p-8">
|
<div class="p-6 md:p-8">
|
||||||
@@ -260,34 +309,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/key}
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Linked Documents Section -->
|
<!-- Co-Correspondents Section -->
|
||||||
<div>
|
{#if coCorrespondents().length > 0}
|
||||||
<div class="flex items-center justify-between mb-6 border-b border-brand-navy/10 pb-2">
|
<div class="mb-6">
|
||||||
<div class="flex items-center gap-3">
|
<h3 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-3">{m.person_co_correspondents_heading()}</h3>
|
||||||
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
|
{#each coCorrespondents() as c}
|
||||||
{documents.length}
|
<a href="/conversations?senderId={person.id}&receiverId={c.id}"
|
||||||
</span>
|
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-brand-sand text-sm font-serif text-brand-navy hover:border-brand-navy transition-colors">
|
||||||
</div>
|
{c.name}
|
||||||
{#if documents.length > 0}
|
<span class="text-xs text-gray-400 font-sans">({c.count})</span>
|
||||||
<button
|
</a>
|
||||||
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
|
{/each}
|
||||||
class="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-brand-navy transition-colors"
|
</div>
|
||||||
aria-label={m.conv_sort_label()}
|
</div>
|
||||||
>
|
{/if}
|
||||||
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
|
||||||
</button>
|
<!-- Document Statistics Bar -->
|
||||||
|
{#if docStats().total > 0}
|
||||||
|
<div class="mb-8 px-4 py-3 bg-brand-sand/30 rounded-sm flex items-center gap-2 font-sans text-sm text-brand-navy/70">
|
||||||
|
<span>{docStats().total} Dokumente</span>
|
||||||
|
{#if docStats().minYear !== null}
|
||||||
|
<span class="text-brand-mint">·</span>
|
||||||
|
{#if docStats().minYear === docStats().maxYear}
|
||||||
|
<span>{docStats().minYear}</span>
|
||||||
|
{:else}
|
||||||
|
<span>{docStats().minYear} – {docStats().maxYear}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sort control -->
|
||||||
|
{#if allDocuments.length > 0}
|
||||||
|
<div class="flex justify-end mb-4">
|
||||||
|
<button
|
||||||
|
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
|
||||||
|
class="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-brand-navy transition-colors"
|
||||||
|
aria-label={m.conv_sort_label()}
|
||||||
|
>
|
||||||
|
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sent Documents Section -->
|
||||||
|
<div class="mb-10">
|
||||||
|
<div class="flex items-center gap-3 mb-6 border-b border-brand-navy/10 pb-2">
|
||||||
|
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
|
||||||
|
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
|
||||||
|
{sentDocuments.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if documents.length === 0}
|
{#if sentDocuments.length === 0}
|
||||||
<div class="p-12 text-center bg-white border border-brand-sand border-dashed rounded-sm">
|
<div class="p-12 text-center bg-white border border-brand-sand border-dashed rounded-sm">
|
||||||
<p class="text-gray-500 font-sans">{m.person_no_docs()}</p>
|
<p class="text-gray-500 font-sans">{m.person_no_docs()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
{#each sortedDocuments as doc}
|
{#each visibleSentDocuments as doc}
|
||||||
<li class="group">
|
<li class="group">
|
||||||
<a href="/documents/{doc.id}" class="block bg-white border border-brand-sand p-4 hover:border-brand-navy hover:shadow-md transition-all duration-200">
|
<a href="/documents/{doc.id}" class="block bg-white border border-brand-sand p-4 hover:border-brand-navy hover:shadow-md transition-all duration-200">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -308,20 +391,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center flex-shrink-0 pl-4">
|
<div class="flex items-center flex-shrink-0 pl-4 gap-2">
|
||||||
<span class="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border
|
<span class="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border
|
||||||
{doc.status === 'UPLOADED'
|
{doc.status === 'UPLOADED'
|
||||||
? 'bg-brand-mint/20 text-brand-navy border-brand-mint/50'
|
? 'bg-brand-mint/20 text-brand-navy border-brand-mint/50'
|
||||||
: 'bg-yellow-50 text-yellow-800 border-yellow-200'}">
|
: 'bg-yellow-50 text-yellow-800 border-yellow-200'}">
|
||||||
{doc.status}
|
{doc.status}
|
||||||
</span>
|
</span>
|
||||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg" alt="" aria-hidden="true" class="h-5 w-5 ml-4 opacity-40 group-hover:opacity-100 transition-opacity" />
|
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg" alt="" aria-hidden="true" class="h-5 w-5 ml-2 opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{#if sentDocuments.length > DOCS_PREVIEW_LIMIT && !showAllSent}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAllSent = true)}
|
||||||
|
class="mt-3 text-xs font-bold uppercase tracking-widest text-brand-navy/50 hover:text-brand-navy transition-colors"
|
||||||
|
>
|
||||||
|
+ {sentDocuments.length - DOCS_PREVIEW_LIMIT} weitere anzeigen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Received Documents Section -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-6 border-b border-brand-navy/10 pb-2">
|
||||||
|
<h2 class="text-xl font-serif text-brand-navy">{m.person_received_docs_heading()}</h2>
|
||||||
|
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
|
||||||
|
{receivedDocuments.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if receivedDocuments.length === 0}
|
||||||
|
<div class="p-12 text-center bg-white border border-brand-sand border-dashed rounded-sm">
|
||||||
|
<p class="text-gray-500 font-sans">{m.person_no_received_docs()}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each visibleReceivedDocuments as doc}
|
||||||
|
<li class="group">
|
||||||
|
<a href="/documents/{doc.id}" class="block bg-white border border-brand-sand p-4 hover:border-brand-navy hover:shadow-md transition-all duration-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4 overflow-hidden">
|
||||||
|
<div class="flex-shrink-0 h-10 w-10 bg-brand-sand/20 text-brand-navy rounded flex items-center justify-center group-hover:bg-brand-mint group-hover:text-brand-navy transition-colors">
|
||||||
|
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-serif text-base font-medium text-brand-navy truncate group-hover:underline decoration-brand-mint decoration-2 underline-offset-2">
|
||||||
|
{doc.title || doc.originalFilename}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-xs font-sans text-gray-500 mt-0.5 space-x-2">
|
||||||
|
<span>{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : m.doc_no_date()}</span>
|
||||||
|
{#if doc.location}
|
||||||
|
<span class="text-brand-mint">•</span>
|
||||||
|
<span>{doc.location}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center flex-shrink-0 pl-4 gap-2">
|
||||||
|
<span class="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border
|
||||||
|
{doc.status === 'UPLOADED'
|
||||||
|
? 'bg-brand-mint/20 text-brand-navy border-brand-mint/50'
|
||||||
|
: 'bg-yellow-50 text-yellow-800 border-yellow-200'}">
|
||||||
|
{doc.status}
|
||||||
|
</span>
|
||||||
|
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg" alt="" aria-hidden="true" class="h-5 w-5 ml-2 opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if receivedDocuments.length > DOCS_PREVIEW_LIMIT && !showAllReceived}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAllReceived = true)}
|
||||||
|
class="mt-3 text-xs font-bold uppercase tracking-widest text-brand-navy/50 hover:text-brand-navy transition-colors"
|
||||||
|
>
|
||||||
|
+ {receivedDocuments.length - DOCS_PREVIEW_LIMIT} weitere anzeigen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
|
export async function load({ locals }: { locals: App.Locals }) {
|
||||||
|
const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false;
|
||||||
|
if (!canWrite) throw error(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, fetch }) => {
|
default: async ({ request, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|||||||
Reference in New Issue
Block a user