feat: Phase 2 — Delegated State, Auth, Review-Gate, Notifications, Zombie-Reset
CI - Build & Test / Backend (.NET) (push) Successful in 37s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 24s
CI - Build & Test / Security Check (push) Successful in 4s

This commit is contained in:
2026-06-18 23:47:41 +02:00
parent 12998170e3
commit dcc8450c62
32 changed files with 1758 additions and 38 deletions
+249
View File
@@ -0,0 +1,249 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationStore } from '../stores/notifications'
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
const store = useNotificationStore()
const router = useRouter()
const sortedNotifications = computed(() => {
return [...store.notifications].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
})
function typeIcon(type: string): string {
switch (type) {
case 'task_assigned': return '👤'
case 'task_review': return '✅'
case 'task_blocked': return '🚫'
default: return '🔔'
}
}
function typeColor(type: string): string {
switch (type) {
case 'task_assigned': return '#4d8cf6'
case 'task_review': return '#f6a84d'
case 'task_blocked': return '#e16e75'
default: return '#7b6ef2'
}
}
function timeAgo(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffSec = Math.floor((now - then) / 1000)
if (diffSec < 60) return 'vor ' + diffSec + ' Sek'
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return 'vor ' + diffMin + ' Min'
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return 'vor ' + diffHr + ' Std'
const diffDay = Math.floor(diffHr / 24)
return 'vor ' + diffDay + ' Tag' + (diffDay > 1 ? 'en' : '')
}
function onNotificationClick(n: { id: string, taskId: string | null }) {
if (n.taskId) {
router.push('/tasks')
}
store.markAsRead(n.id)
}
onMounted(() => {
store.startListPolling()
})
onUnmounted(() => {
store.stopListPolling()
})
</script>
<template>
<div class="notifications-page">
<div class="page-header">
<h1>
<Bell :size="22" />
Benachrichtigungen
</h1>
<button
v-if="store.unreadCount > 0"
class="mark-all-btn"
@click="store.markAllAsRead()"
>
<CheckCheck :size="15" />
Alle als gelesen markieren
</button>
</div>
<div v-if="sortedNotifications.length === 0" class="empty-state">
<BellOff :size="48" />
<p>Keine Benachrichtigungen</p>
</div>
<div v-else class="notification-list">
<div
v-for="n in sortedNotifications"
:key="n.id"
:class="['notification-card', { unread: !n.isRead }]"
@click="onNotificationClick(n)"
>
<div class="icon-wrapper" :style="{ background: typeColor(n.type) + '20' }">
<span class="type-icon">{{ typeIcon(n.type) }}</span>
</div>
<div class="card-body">
<div :class="['card-title', { bold: !n.isRead }]">{{ n.title }}</div>
<div v-if="n.message" class="card-message">{{ n.message }}</div>
</div>
<div class="card-meta">
<span class="timestamp">{{ timeAgo(n.createdAt) }}</span>
<ChevronRight v-if="n.taskId" :size="14" class="arrow" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.notifications-page {
max-width: 720px;
margin: 0 auto;
padding: 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 12px;
}
.page-header h1 {
margin: 0;
font-size: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.mark-all-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--nx-line, #1f2330);
border-radius: 6px;
background: transparent;
color: var(--nx-text-dim, #6f7889);
font-size: 10.5px;
cursor: pointer;
transition: background .15s, color .15s;
}
.mark-all-btn:hover {
background: var(--nx-accent-soft, rgba(123, 110, 242, .08));
color: #d8dbe3;
}
/* ── Empty State ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
color: var(--nx-text-dim, #6f7889);
gap: 16px;
}
.empty-state p {
font-size: 14px;
margin: 0;
}
/* ── Notification List ── */
.notification-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.notification-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
border: 1px solid transparent;
}
.notification-card:hover {
background: var(--nx-accent-soft, rgba(123, 110, 242, .06));
}
.notification-card.unread {
background: rgba(77, 140, 246, .06);
border-color: rgba(77, 140, 246, .12);
}
.icon-wrapper {
width: 36px;
height: 36px;
border-radius: 8px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.type-icon {
font-size: 16px;
line-height: 1;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 12.5px;
color: #d8dbe3;
line-height: 1.4;
}
.card-title.bold {
font-weight: 700;
color: #fff;
}
.card-message {
font-size: 10.5px;
color: var(--nx-text-dim, #6f7889);
margin-top: 3px;
line-height: 1.3;
}
.card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
min-width: 60px;
}
.timestamp {
font-size: 9px;
color: var(--nx-text-dim, #6f7889);
white-space: nowrap;
}
.arrow {
color: var(--nx-text-dim, #6f7889);
opacity: .5;
}
</style>
+40
View File
@@ -218,6 +218,46 @@ onUnmounted(() => {
</div>
</div>
<!-- Delegiert / Delegated -->
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'delegated' }"
@dragover="onDragOver($event, 'delegated')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'delegated')"
>
<div class="col-header" style="--col-accent: #a78bfa">
<span class="col-name">Delegiert</span>
<span class="col-count">{{ taskStore.board.delegated.length }}</span>
</div>
<div class="col-cards">
<div
v-for="task in taskStore.board.delegated"
:key="task.id"
class="card"
draggable="true"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<span class="prio-badge" :style="{ color: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
</div>
<div v-if="!taskStore.board.delegated.length" class="empty-col">Keine delegierten Aufgaben</div>
</div>
</div>
<!-- Review -->
<div
class="col"