restructure: flatten workspace nesting, move devcontainer to root
- backend/workspaces/backend/ → backend/ - backend/workspaces/frontend/ → frontend/ - backend/.devcontainer/ + .vscode/ → repo root (where VS Code expects them) - loose scripts/SQL files → scripts/ - replace nested git repo with single repo at project root - update docker-compose.yml build context and devcontainer.json path - add root .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
62
frontend/src/routes/conversations/+page.server.ts
Normal file
62
frontend/src/routes/conversations/+page.server.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
|
||||
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
|
||||
// 1. Parameter auslesen
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const dir = url.searchParams.get('dir') || 'DESC';
|
||||
|
||||
let documents = [];
|
||||
let senderName = '';
|
||||
let receiverName = '';
|
||||
|
||||
// 2. Fetch-Requests vorbereiten
|
||||
const requests = [];
|
||||
|
||||
// Dokumente laden (nur wenn beide IDs da sind)
|
||||
if (senderId && receiverId) {
|
||||
const query = new URLSearchParams({ senderId, receiverId, dir });
|
||||
if (from) query.set('from', from);
|
||||
if (to) query.set('to', to);
|
||||
requests.push(
|
||||
fetch(`${baseUrl}/api/documents/conversation?${query}`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => documents = data)
|
||||
);
|
||||
}
|
||||
|
||||
// Namen auflösen für Typeahead Initial Value
|
||||
if (senderId) {
|
||||
requests.push(
|
||||
fetch(`${baseUrl}/api/persons/${senderId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(p => senderName = p ? `${p.firstName} ${p.lastName}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
if (receiverId) {
|
||||
requests.push(
|
||||
fetch(`${baseUrl}/api/persons/${receiverId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(p => receiverName = p ? `${p.firstName} ${p.lastName}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
// Alles parallel abfeuern
|
||||
await Promise.all(requests);
|
||||
|
||||
return {
|
||||
documents,
|
||||
initialValues: {
|
||||
senderName,
|
||||
receiverName
|
||||
},
|
||||
filters: { senderId, receiverId, from, to, dir }
|
||||
};
|
||||
}
|
||||
272
frontend/src/routes/conversations/+page.svelte
Normal file
272
frontend/src/routes/conversations/+page.svelte
Normal file
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
// Data & State
|
||||
let documents = [];
|
||||
let initialValues = { senderName: '', receiverName: '' };
|
||||
|
||||
// Filter State
|
||||
let senderId = '';
|
||||
let receiverId = '';
|
||||
let fromDate = '';
|
||||
let toDate = '';
|
||||
let sortDir = 'DESC';
|
||||
|
||||
// Reactive Update
|
||||
$: {
|
||||
documents = data.documents;
|
||||
initialValues = data.initialValues;
|
||||
senderId = data.filters.senderId;
|
||||
receiverId = data.filters.receiverId;
|
||||
fromDate = data.filters.from;
|
||||
toDate = data.filters.to;
|
||||
sortDir = data.filters.dir;
|
||||
}
|
||||
|
||||
// Filter Logic
|
||||
function applyFilters() {
|
||||
setTimeout(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (senderId) params.set('senderId', senderId);
|
||||
if (receiverId) params.set('receiverId', receiverId);
|
||||
if (fromDate) params.set('from', fromDate);
|
||||
if (toDate) params.set('to', toDate);
|
||||
params.set('dir', sortDir);
|
||||
goto(`?${params.toString()}`, { keepFocus: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function handleSenderChange(event: CustomEvent) {
|
||||
senderId = event.detail.value;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function handleReceiverChange(event: CustomEvent) {
|
||||
receiverId = event.detail.value;
|
||||
applyFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-5xl mx-auto py-10 px-4">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 border-b border-brand-navy/10 pb-4">
|
||||
<h1 class="text-3xl font-serif font-medium text-brand-navy">Konversationen</h1>
|
||||
<p class="text-brand-navy/60 font-sans text-sm mt-2">
|
||||
Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- FILTER BAR -->
|
||||
<div class="bg-white p-8 shadow-sm border border-brand-sand mb-10 relative z-20">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
|
||||
<!-- Sender -->
|
||||
<div
|
||||
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label="Person A (Absender)"
|
||||
value={senderId}
|
||||
initialName={initialValues.senderName}
|
||||
on:change={handleSenderChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Receiver -->
|
||||
<div
|
||||
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
label="Person B (Empfänger)"
|
||||
value={receiverId}
|
||||
initialName={initialValues.receiverName}
|
||||
on:change={handleReceiverChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-end relative z-10">
|
||||
<!-- Date From -->
|
||||
<div>
|
||||
<label
|
||||
for="dateFrom"
|
||||
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
|
||||
>Zeitraum von</label
|
||||
>
|
||||
<input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
bind:value={fromDate}
|
||||
on:change={() => applyFilters()}
|
||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date To -->
|
||||
<div>
|
||||
<label
|
||||
for="dateTo"
|
||||
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
|
||||
>Zeitraum bis</label
|
||||
>
|
||||
<input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
bind:value={toDate}
|
||||
on:change={() => applyFilters()}
|
||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sort Toggle -->
|
||||
<div>
|
||||
<button
|
||||
on:click={toggleSort}
|
||||
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
|
||||
>
|
||||
<span class="mr-2">Sortierung:</span>
|
||||
<span>{sortDir === 'DESC' ? 'Neueste zuerst' : 'Älteste zuerst'}</span>
|
||||
<svg
|
||||
class="w-4 h-4 ml-2 transform {sortDir === 'ASC'
|
||||
? 'rotate-180'
|
||||
: ''} transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RESULTS LIST SECTION -->
|
||||
{#if !senderId || !receiverId}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand border-dashed rounded-sm text-center"
|
||||
>
|
||||
<div class="bg-brand-sand/30 p-4 rounded-full mb-4 text-brand-navy">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<p class="text-brand-navy font-serif text-lg">Wählen Sie zwei Personen aus</p>
|
||||
<p class="text-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
|
||||
</div>
|
||||
{:else if documents.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
|
||||
>
|
||||
<p class="text-brand-navy font-serif">Keine Dokumente gefunden.</p>
|
||||
<p class="text-gray-400 text-sm mt-2">Versuchen Sie, den Zeitraum anzupassen.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- CHAT CONTAINER -->
|
||||
<!-- Added: White background, Border, Shadow to separate from page -->
|
||||
<div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
|
||||
<!-- Decoration: Central Timeline Line -->
|
||||
<div
|
||||
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
|
||||
></div>
|
||||
|
||||
<!-- Scrollable Area (optional, if you want max-height) -->
|
||||
<div class="p-6 md:p-8">
|
||||
<!-- TIGHTER GAP: Changed from gap-8 to gap-4 -->
|
||||
<div class="flex flex-col gap-4 relative z-10">
|
||||
{#each documents as doc}
|
||||
{@const isRight = doc.sender?.id === senderId}
|
||||
|
||||
<!-- Message Row -->
|
||||
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
|
||||
<!-- Bubble Group -->
|
||||
<div
|
||||
class="flex max-w-[90%] md:max-w-[70%] gap-3 {isRight
|
||||
? 'flex-row-reverse'
|
||||
: 'flex-row'}"
|
||||
>
|
||||
<!-- AVATAR (Small) -->
|
||||
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
|
||||
{isRight
|
||||
? 'bg-brand-navy text-white border-brand-navy'
|
||||
: 'bg-white text-brand-navy border-brand-sand'}"
|
||||
>
|
||||
{#if doc.sender}
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
{:else}
|
||||
?
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BUBBLE CARD -->
|
||||
<!-- Adjusted padding (p-4) and added light bg to left bubbles for contrast -->
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border
|
||||
{isRight
|
||||
? 'bg-brand-navy text-white border-brand-navy rounded-br-none'
|
||||
: 'bg-brand-sand/10 text-brand-navy border-brand-sand rounded-bl-none'}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start gap-4 mb-2">
|
||||
<h3
|
||||
class="font-serif font-medium text-sm leading-snug {isRight
|
||||
? 'text-white'
|
||||
: 'text-brand-navy'}"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h3>
|
||||
|
||||
<!-- Status Dot -->
|
||||
<span
|
||||
class="flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5
|
||||
{doc.status === 'UPLOADED'
|
||||
? 'bg-brand-mint'
|
||||
: 'bg-yellow-400'}"
|
||||
title={doc.status}
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div
|
||||
class="flex flex-wrap gap-3 text-[10px] font-sans uppercase tracking-wider opacity-80 {isRight
|
||||
? 'text-blue-100'
|
||||
: 'text-gray-500'}"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{doc.documentDate || '—'}
|
||||
</span>
|
||||
{#if doc.location}
|
||||
<span class="flex items-center">
|
||||
• {doc.location}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user