feat: Phase 2 — Delegated State, Auth, Review-Gate, Notifications, Zombie-Reset
This commit is contained in:
@@ -23,7 +23,7 @@ const activeView = computed(() => {
|
||||
const routePaths: Record<string, string> = {
|
||||
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
|
||||
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
|
||||
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings',
|
||||
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
|
||||
}
|
||||
|
||||
const navigate = (label: string) => {
|
||||
@@ -32,7 +32,7 @@ const navigate = (label: string) => {
|
||||
}
|
||||
const mobileNavOpen = ref(false)
|
||||
|
||||
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board'].includes(activeView.value))
|
||||
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'Notifications'].includes(activeView.value))
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated) store.refresh()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import {
|
||||
Activity, Bot, Boxes, Command, FileText,
|
||||
Activity, Bell, Bot, Boxes, Command, FileText,
|
||||
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
|
||||
Shield, SlidersHorizontal, Sparkles, BookOpen,
|
||||
AlertTriangle, Calendar,
|
||||
} from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useNotificationStore } from '../../stores/notifications'
|
||||
import { initials } from '../../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -23,6 +24,11 @@ const emit = defineEmits<{
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
onMounted(() => {
|
||||
notificationStore.startPolling()
|
||||
})
|
||||
|
||||
const ownerInitials = computed(() =>
|
||||
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||
@@ -37,6 +43,7 @@ const navigation = [
|
||||
{ label: 'Task Board', icon: ListTodo },
|
||||
{ label: 'Incidents', icon: AlertTriangle },
|
||||
{ separator: true },
|
||||
{ label: 'Notifications', icon: Bell },
|
||||
{ label: 'Calendar', icon: Calendar },
|
||||
{ label: 'Agents', icon: Bot },
|
||||
{ label: 'Models', icon: SlidersHorizontal },
|
||||
@@ -76,6 +83,7 @@ async function logout() {
|
||||
<span>{{ item.label }}</span>
|
||||
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
|
||||
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
|
||||
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
@@ -154,6 +162,9 @@ async function logout() {
|
||||
border-radius: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.nav button i.badge-red {
|
||||
background: #e16e75;
|
||||
}
|
||||
.nav-separator {
|
||||
height: 1px;
|
||||
margin: 6px 10px;
|
||||
|
||||
@@ -12,6 +12,7 @@ import CalendarView from './views/CalendarView.vue'
|
||||
import NexusLayout from './layouts/NexusLayout.vue'
|
||||
import FlowBoard from './views/Dashboard/FlowBoard.vue'
|
||||
import TaskBoardView from './views/TaskBoardView.vue'
|
||||
import NotificationsView from './views/NotificationsView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
||||
@@ -39,6 +40,7 @@ const routes = [
|
||||
{ path: '/models', name: 'Models', component: { template: '' } },
|
||||
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
||||
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
|
||||
{ path: '/notifications', name: 'Notifications', component: NotificationsView },
|
||||
{ path: '/settings', name: 'Settings', component: SettingsView },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Notification Store – Polls unread count and notifications from the API.
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiFetch } from '../services/api'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string
|
||||
type: string // "task_assigned", "task_review", "task_blocked"
|
||||
title: string
|
||||
message: string | null
|
||||
forUser: string
|
||||
taskId: string | null
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', {
|
||||
state: () => ({
|
||||
notifications: [] as NotificationItem[],
|
||||
unreadCount: 0,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
countRefreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||
listRefreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchNotifications(forUser = 'bao', limit = 50, unreadOnly = false) {
|
||||
this.loading = true
|
||||
try {
|
||||
const params = new URLSearchParams({ forUser, limit: String(limit), unreadOnly: String(unreadOnly) })
|
||||
const res = await apiFetch(`/api/dashboard/notifications?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
this.notifications = await res.json()
|
||||
this.error = null
|
||||
} catch (err) {
|
||||
console.warn('[NotificationStore] fetchNotifications failed', err)
|
||||
this.error = 'Notifications could not be loaded'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchUnreadCount(forUser = 'bao') {
|
||||
try {
|
||||
const params = new URLSearchParams({ forUser })
|
||||
const res = await apiFetch(`/api/dashboard/notifications/unread-count?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: UnreadCount = await res.json()
|
||||
this.unreadCount = data.count
|
||||
} catch (err) {
|
||||
console.warn('[NotificationStore] fetchUnreadCount failed', err)
|
||||
}
|
||||
},
|
||||
|
||||
async markAsRead(id: string) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/notifications/${id}/read`, { method: 'PATCH' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
// Update local state
|
||||
const n = this.notifications.find(n => n.id === id)
|
||||
if (n) n.isRead = true
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
} catch (err) {
|
||||
console.warn('[NotificationStore] markAsRead failed', err)
|
||||
}
|
||||
},
|
||||
|
||||
async markAllAsRead(forUser = 'bao') {
|
||||
try {
|
||||
const params = new URLSearchParams({ forUser })
|
||||
const res = await apiFetch(`/api/dashboard/notifications/read-all?${params}`, { method: 'PATCH' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
this.notifications.forEach(n => { n.isRead = true })
|
||||
this.unreadCount = 0
|
||||
} catch (err) {
|
||||
console.warn('[NotificationStore] markAllAsRead failed', err)
|
||||
}
|
||||
},
|
||||
|
||||
startPolling(forUser = 'bao') {
|
||||
// Unread count polling every 30s (for sidebar badge)
|
||||
if (!this.countRefreshInterval) {
|
||||
this.fetchUnreadCount(forUser)
|
||||
this.countRefreshInterval = setInterval(() => {
|
||||
this.fetchUnreadCount(forUser)
|
||||
}, 30000)
|
||||
}
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
if (this.countRefreshInterval) {
|
||||
clearInterval(this.countRefreshInterval)
|
||||
this.countRefreshInterval = null
|
||||
}
|
||||
if (this.listRefreshInterval) {
|
||||
clearInterval(this.listRefreshInterval)
|
||||
this.listRefreshInterval = null
|
||||
}
|
||||
},
|
||||
|
||||
startListPolling(forUser = 'bao') {
|
||||
if (!this.listRefreshInterval) {
|
||||
this.fetchNotifications(forUser)
|
||||
this.listRefreshInterval = setInterval(() => {
|
||||
this.fetchNotifications(forUser)
|
||||
}, 30000)
|
||||
}
|
||||
},
|
||||
|
||||
stopListPolling() {
|
||||
if (this.listRefreshInterval) {
|
||||
clearInterval(this.listRefreshInterval)
|
||||
this.listRefreshInterval = null
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -28,6 +28,7 @@ interface DashboardTaskDto {
|
||||
export interface BoardGroup {
|
||||
offen: DashboardTaskDto[]
|
||||
inProgress: DashboardTaskDto[]
|
||||
delegated: DashboardTaskDto[]
|
||||
review: DashboardTaskDto[]
|
||||
done: DashboardTaskDto[]
|
||||
blocked: DashboardTaskDto[]
|
||||
@@ -82,6 +83,7 @@ export const useTaskStore = defineStore('tasks', {
|
||||
board: {
|
||||
offen: [] as DashboardTaskDto[],
|
||||
inProgress: [] as DashboardTaskDto[],
|
||||
delegated: [] as DashboardTaskDto[],
|
||||
review: [] as DashboardTaskDto[],
|
||||
done: [] as DashboardTaskDto[],
|
||||
blocked: [] as DashboardTaskDto[],
|
||||
@@ -135,6 +137,7 @@ export const useTaskStore = defineStore('tasks', {
|
||||
const canonicalMap: Record<string, string> = {
|
||||
offen: 'Backlog',
|
||||
inProgress: 'In progress',
|
||||
delegated: 'Delegated',
|
||||
review: 'Review',
|
||||
done: 'Done',
|
||||
blocked: 'Blocked',
|
||||
@@ -153,6 +156,7 @@ export const useTaskStore = defineStore('tasks', {
|
||||
const task =
|
||||
findAndRemove(this.board.offen) ??
|
||||
findAndRemove(this.board.inProgress) ??
|
||||
findAndRemove(this.board.delegated) ??
|
||||
findAndRemove(this.board.review) ??
|
||||
findAndRemove(this.board.blocked) ??
|
||||
findAndRemove(this.board.done)
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user