feat: Bao/Iris-Statusrechte + Bao→Iris-Notifications + Agent-Workflow-Übersicht
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s

- Bao darf jetzt Status ändern (neben Iris), Sub-Agents weiterhin nicht
- CanEditContent für Inhaltsbearbeitung durch alle bekannten Caller
- Bao-Content-Änderungen triggern task_content_changed-Notification an Iris
- Bao-Status-Änderungen triggern task_status_changed-Notification an Iris
- Iris-Status-Änderungen triggern task_status_changed-Notification an Bao
- Neue WorkTask-Felder: IsAgentTask (bool), ExpectedFrom (string)
- Agent-Workflow-API: CreateAgentTask, WaitingTasks, AgentOverview
- Frontend: Agent-Task-Badge, Iris-Overview-Panel, isBao-Getter
- Login-Rate-Limiter mit strukturiertem JSON-Fehlermeldungs-Body
- Volume-Name: nexus-postgres → postgres-data (Standardisierung)
This commit is contained in:
2026-06-20 18:42:51 +02:00
parent a516353ae8
commit 83e072bc27
21 changed files with 1690 additions and 80 deletions
+86 -3
View File
@@ -13,6 +13,12 @@ interface AuthPayload {
user: AuthUser
}
interface LoginErrorInfo {
message: string
remaining: number
retryAfterSeconds: number
}
let refreshInFlight: Promise<boolean> | null = null
export const useAuthStore = defineStore('auth', {
@@ -22,28 +28,51 @@ export const useAuthStore = defineStore('auth', {
user: null as AuthUser | null,
initialized: false,
loading: false,
/** Remaining login attempts in the current window (null = unknown) */
remainingAttempts: null as number | null,
/** Seconds until rate-limit reset (0 = not rate-limited) */
retryAfterSeconds: 0,
}),
getters: {
isAuthenticated: state => Boolean(state.accessToken && state.user),
isRateLimited: state => state.remainingAttempts === 0 && state.retryAfterSeconds > 0,
/** Returns true if the current web-ui user is Iris (JWT user identity matches "iris"). */
isIris: state => {
if (!state.user) return false
const lower = state.user.email.toLowerCase()
return lower.includes('iris') || state.user.displayName.toLowerCase().includes('iris')
},
/** Returns true if the current web-ui user is Bao (JWT user identity matches "bao"). */
isBao: state => {
if (!state.user) return false
const lower = state.user.email.toLowerCase()
return lower.includes('bao') || state.user.displayName.toLowerCase().includes('bao')
},
},
actions: {
applySession(payload: AuthPayload) {
this.accessToken = payload.accessToken
this.expiresAt = payload.expiresAt
this.user = payload.user
this.remainingAttempts = null
this.retryAfterSeconds = 0
},
clearSession() {
this.accessToken = null
this.expiresAt = null
this.user = null
this.remainingAttempts = null
this.retryAfterSeconds = 0
},
async initialize() {
if (this.initialized) return this.isAuthenticated
this.initialized = true
return this.refresh()
},
async login(email: string, password: string) {
async login(email: string, password: string): Promise<void> {
this.loading = true
this.remainingAttempts = null
this.retryAfterSeconds = 0
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
@@ -52,9 +81,50 @@ export const useAuthStore = defineStore('auth', {
body: JSON.stringify({ email, password }),
})
// Try to parse remaining from headers
const remainingHeader = response.headers.get('X-RateLimit-Remaining')
if (remainingHeader !== null) {
this.remainingAttempts = parseInt(remainingHeader, 10)
}
const resetHeader = response.headers.get('X-RateLimit-Reset')
if (resetHeader !== null) {
const resetTs = parseInt(resetHeader, 10) * 1000
this.retryAfterSeconds = Math.max(0, Math.ceil((resetTs - Date.now()) / 1000))
}
if (!response.ok) {
if (response.status === 429) throw new Error('Too many attempts. Please wait one minute.')
throw new Error('Invalid email or password.')
// Try to parse structured JSON body for rate-limit info
let remaining = this.remainingAttempts
let retryAfter = this.retryAfterSeconds
try {
const body = await response.json() as Record<string, unknown>
if (typeof body.remaining === 'number') remaining = body.remaining
if (typeof body.retryAfterSeconds === 'number') retryAfter = body.retryAfterSeconds
if (response.status === 429) {
this.remainingAttempts = 0
this.retryAfterSeconds = retryAfter
throw new LoginError(body.message as string || 'Too many attempts.', 0, retryAfter)
} else if (response.status === 401) {
this.remainingAttempts = remaining
this.retryAfterSeconds = retryAfter
throw new LoginError(body.message as string || 'Invalid email or password.', remaining, retryAfter)
}
} catch (error) {
if (error instanceof LoginError) throw error
// Fallback for non-JSON error responses
}
if (response.status === 429) {
this.remainingAttempts = 0
const retryAfterSec = this.retryAfterSeconds || 60
this.retryAfterSeconds = retryAfterSec
throw new LoginError('Too many attempts. Please wait.', 0, retryAfterSec)
}
throw new LoginError('Invalid email or password.', this.remainingAttempts ?? 4, this.retryAfterSeconds)
}
this.applySession(await response.json() as AuthPayload)
@@ -101,3 +171,16 @@ export const useAuthStore = defineStore('auth', {
},
},
})
/** Custom error carrying rate-limit metadata. */
class LoginError extends Error {
remaining: number
retryAfterSeconds: number
constructor(message: string, remaining: number, retryAfterSeconds: number) {
super(message)
this.name = 'LoginError'
this.remaining = remaining
this.retryAfterSeconds = retryAfterSeconds
}
}
+76
View File
@@ -25,6 +25,8 @@ export interface DashboardTaskDto {
dueDate?: string | null
createdAt: string
updatedAt: string
isAgentTask?: boolean
expectedFrom?: string | null
}
export interface BoardGroup {
@@ -36,6 +38,14 @@ export interface BoardGroup {
blocked: DashboardTaskDto[]
}
export interface AgentWorkflowOverview {
waitingForBao: DashboardTaskDto[]
waitingForIris: DashboardTaskDto[]
waitingForOthers: DashboardTaskDto[]
staleTasks: DashboardTaskDto[]
staleThreshold: string
}
/* ── State Mapping ────────────────────────────────── */
function mapPriority(priority: string): TaskItem['priority'] {
@@ -92,10 +102,28 @@ export const useTaskStore = defineStore('tasks', {
} as BoardGroup,
boardLoading: false,
boardError: null as string | null,
// Agent Workflow Overview (for Iris)
agentOverview: null as AgentWorkflowOverview | null,
agentOverviewLoading: false,
agentOverviewError: null as string | null,
}),
getters: {
taskList: (state) => state.tasks,
// Iris helpers
waitingForIrisTasks: (state) => state.agentOverview?.waitingForIris ?? [],
waitingForBaoTasks: (state) => state.agentOverview?.waitingForBao ?? [],
waitingForOthersTasks: (state) => state.agentOverview?.waitingForOthers ?? [],
staleTasksList: (state) => state.agentOverview?.staleTasks ?? [],
agentTaskCount: (state) => {
if (!state.agentOverview) return 0
return state.agentOverview.waitingForBao.length +
state.agentOverview.waitingForIris.length +
state.agentOverview.waitingForOthers.length +
state.agentOverview.staleTasks.length
},
},
actions: {
@@ -267,6 +295,54 @@ export const useTaskStore = defineStore('tasks', {
}
},
/* ── API: Fetch agent workflow overview ──────── */
async fetchAgentOverview(staleHours = 2) {
this.agentOverviewLoading = true
try {
const res = await apiFetch(`/api/dashboard/tasks/agent-overview?staleHours=${staleHours}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: AgentWorkflowOverview = await res.json()
this.agentOverview = data
this.agentOverviewError = null
} catch (err) {
console.warn('[TaskStore] fetchAgentOverview failed', err)
this.agentOverviewError = 'Agent overview could not be loaded'
} finally {
this.agentOverviewLoading = false
}
},
/* ── API: Create agent task ───────────────────── */
async createAgentTask(data: {
title: string
detail?: string | null
source?: string
priority?: string
assignedTo?: string
expectedFrom?: string
}) {
try {
const res = await apiFetch('/api/dashboard/tasks/agent', {
method: 'POST',
body: JSON.stringify({
title: data.title,
detail: data.detail ?? null,
source: data.source ?? 'iris',
priority: data.priority ?? 'Medium',
assignedTo: data.assignedTo ?? null,
expectedFrom: data.expectedFrom ?? null,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await this.fetchBoard()
await this.fetchAgentOverview()
return await res.json() as DashboardTaskDto
} catch (err) {
console.warn('[TaskStore] createAgentTask failed', err)
throw err
}
},
/* ── Polling ──────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
+120 -12
View File
@@ -4,9 +4,10 @@
*
* Vollbild-Login mit GalaxyBackground, Glassmorphismus,
* und Consistent Branding.
* Zeigt verbleibende Login-Versuche und Rate-Limit-Countdown.
*/
import { onMounted, onUnmounted, ref } from 'vue'
import { Mail, LockKeyhole, Command, Eye, EyeOff } from '@lucide/vue'
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { Mail, LockKeyhole, Command, Eye, EyeOff, Clock, AlertTriangle } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
@@ -20,6 +21,38 @@ const password = ref('')
const error = ref('')
const showPassword = ref(false)
// Rate-limit countdown timer
const countdown = ref(0)
let countdownInterval: ReturnType<typeof setInterval> | null = null
const countdownText = computed(() => {
if (countdown.value <= 0) return ''
const m = Math.floor(countdown.value / 60)
const s = countdown.value % 60
return `${m}:${s.toString().padStart(2, '0')}`
})
function startCountdown(seconds: number) {
stopCountdown()
countdown.value = seconds
countdownInterval = setInterval(() => {
countdown.value = Math.max(0, countdown.value - 1)
if (countdown.value <= 0 && countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
}
onUnmounted(() => stopCountdown())
async function submit() {
error.value = ''
try {
@@ -29,7 +62,16 @@ async function submit() {
: '/dashboard'
await router.replace(target)
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Login fehlgeschlagen.'
if (reason instanceof Error) {
error.value = reason.message
// Start countdown if rate-limited
if (auth.retryAfterSeconds > 0) {
startCountdown(auth.retryAfterSeconds)
}
} else {
error.value = 'Login fehlgeschlagen.'
}
}
}
</script>
@@ -71,6 +113,7 @@ async function submit() {
maxlength="120"
placeholder="name@noveria.net"
class="field-input"
:disabled="auth.isRateLimited"
/>
</div>
@@ -90,6 +133,7 @@ async function submit() {
maxlength="200"
placeholder="••••••••••"
class="field-input"
:disabled="auth.isRateLimited"
/>
<button
type="button"
@@ -97,6 +141,7 @@ async function submit() {
@click="showPassword = !showPassword"
:aria-label="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
tabindex="-1"
:disabled="auth.isRateLimited"
>
<Eye v-if="!showPassword" :size="16" />
<EyeOff v-else :size="16" />
@@ -104,13 +149,27 @@ async function submit() {
</div>
</div>
<p v-if="error" class="login-error" role="alert">
{{ error }}
</p>
<!-- Error display with remaining attempts -->
<div v-if="error" class="error-box" role="alert">
<div class="error-main">
<AlertTriangle v-if="countdown > 0" :size="16" class="error-icon" />
<span>{{ error }}</span>
</div>
<div v-if="auth.remainingAttempts !== null && auth.remainingAttempts > 0" class="attempts-remaining">
<LockKeyhole :size="12" />
<span>{{ auth.remainingAttempts }} {{ auth.remainingAttempts === 1 ? 'Versuch verbleibend' : 'Versuche verbleibend' }}</span>
</div>
<div v-if="countdown > 0" class="countdown-bar">
<Clock :size="12" />
<span>Entsperrt in {{ countdownText }}</span>
</div>
</div>
<button type="submit" class="submit-btn" :disabled="auth.loading || !email || !password">
<button type="submit" class="submit-btn" :disabled="auth.loading || !email || !password || auth.isRateLimited">
<LockKeyhole :size="15" />
{{ auth.loading ? 'Anmelden…' : 'Anmelden' }}
<template v-if="auth.loading">Anmelden</template>
<template v-else-if="countdown > 0">Gesperrt ({{ countdownText }})</template>
<template v-else>Anmelden</template>
</button>
</form>
@@ -278,6 +337,11 @@ async function submit() {
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
}
.field-input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.password-wrap {
position: relative;
display: flex;
@@ -310,17 +374,61 @@ async function submit() {
color: #a8a3d6;
}
.login-error {
margin: 0;
padding: 10px 14px;
.toggle-pw:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ── Error Box ─────────────────────── */
.error-box {
padding: 12px 14px;
border-radius: 10px;
background: rgba(244, 63, 94, 0.1);
border: 1px solid rgba(244, 63, 94, 0.2);
color: #fda4af;
font-size: 12.5px;
line-height: 1.4;
line-height: 1.5;
display: flex;
flex-direction: column;
gap: 6px;
}
.error-main {
display: flex;
align-items: center;
gap: 6px;
}
.error-icon {
flex-shrink: 0;
color: #fda4af;
}
.attempts-remaining {
display: flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
color: #f9a8d4;
padding: 4px 8px;
background: rgba(244, 63, 94, 0.06);
border-radius: 6px;
width: fit-content;
}
.countdown-bar {
display: flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
color: #fb923c;
padding: 4px 8px;
background: rgba(251, 146, 60, 0.08);
border-radius: 6px;
width: fit-content;
}
/* ── Submit Button ─────────────────── */
.submit-btn {
display: flex;
align-items: center;
+1 -1
View File
@@ -4,7 +4,7 @@
*
* - Profile (Display-Name ändern)
* - Passwort ändern
* - Admin: User-Liste + User anlegen (nur für owner-Rolle sichtbar)
* - Admin: User-Liste + User anlegen (für owner- und admin-Rollen sichtbar)
*/
import { onMounted, ref } from 'vue'
import {
+260 -18
View File
@@ -5,10 +5,17 @@
*
* 6 columns: Offen, In Bearbeitung, Delegiert, Review, Blockiert, Erledigt
* HTML5 Drag & Drop (no external lib)
*
* Agent-Workflow Features:
* - Agent-Tasks have a 🤖 badge
* - ExpectedFrom field shows who is expected to act next
* - Stale-task warning banner at top (InProgress/Delegated > 2h)
* - Waiting section for Iris overview
*/
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save } from '@lucide/vue'
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useTaskStore } from '../stores/tasks'
type BoardTask = ReturnType<typeof flattenBoard>[number]
@@ -22,10 +29,12 @@ type TaskFormState = {
dueDate: string
}
const authStore = useAuthStore()
const taskStore = useTaskStore()
const router = useRouter()
const showCreateModal = ref(false)
const showDetailPanel = ref(false)
const showIrisPanel = ref(false)
const dragOverColumn = ref<string | null>(null)
const selectedTaskId = ref<string | null>(null)
const detailSaving = ref(false)
@@ -87,6 +96,10 @@ async function handleCreateTask() {
const draggedTaskId = ref<string | null>(null)
function onDragStart(e: DragEvent, taskId: string) {
if (!canChangeState.value) {
e.preventDefault()
return
}
if (!e.dataTransfer) return
draggedTaskId.value = taskId
e.dataTransfer.effectAllowed = 'move'
@@ -103,6 +116,7 @@ function onDragEnd(e: DragEvent) {
}
function onDragOver(e: DragEvent, column: string) {
if (!canChangeState.value) return
e.preventDefault()
if (!e.dataTransfer) return
e.dataTransfer.dropEffect = 'move'
@@ -114,6 +128,7 @@ function onDragLeave(_e: DragEvent) {
}
async function onDrop(e: DragEvent, targetState: string) {
if (!canChangeState.value) return
e.preventDefault()
dragOverColumn.value = null
const taskId = e.dataTransfer?.getData('text/plain')
@@ -179,6 +194,13 @@ const allBoardTasks = computed(() => flattenBoard())
const selectedTask = computed(() => allBoardTasks.value.find(task => task.id === selectedTaskId.value) ?? null)
const canSaveDetail = computed(() => detailForm.title.trim().length > 0 && !detailSaving.value)
/**
* Policy: Iris und Bao dürfen Status ändern / verschieben.
* Wenn der aktuelle Web-UI-User weder Iris noch Bao ist, werden
* Drag & Drop und die Status-Dropdowns deaktiviert.
*/
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
function hydrateDetailForm(task: BoardTask | null) {
detailError.value = ''
detailSuccess.value = ''
@@ -191,12 +213,49 @@ function hydrateDetailForm(task: BoardTask | null) {
detailForm.dueDate = toDateInputValue(task.dueDate)
}
async function openTask(taskId: string) {
/* ── Iris Panel helpers ─────────────────────────── */
const staleCount = computed(() => taskStore.staleTasksList.length)
const waitingForIrisCount = computed(() => taskStore.waitingForIrisTasks.length)
const waitingForBaoCount = computed(() => taskStore.waitingForBaoTasks.length)
function expectedFromLabel(expected: string | null | undefined): string {
if (!expected) return ''
const map: Record<string, string> = {
'bao': '👤 Bao',
'iris': '🤖 Iris',
'programmer': '🛠 Programmer',
'reviewer': '🔎 Reviewer',
'architekt': '🏛 Architekt',
}
return map[expected.toLowerCase()] ?? expected
}
function hoursSince(dateStr: string): number {
const now = Date.now()
const then = new Date(dateStr).getTime()
return Math.round((now - then) / 3600000)
}
/* ── Task Navigation ───────────────────────────── */
function navigateToTask(taskId: string) {
router.push('/tasks/' + taskId)
}
async function openQuickPeek(taskId: string) {
selectedTaskId.value = taskId
showDetailPanel.value = true
await loadDetailContext(taskId)
}
function handleCardClick(event: MouseEvent, taskId: string) {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
openQuickPeek(taskId)
} else {
navigateToTask(taskId)
}
}
function closeDetailPanel() {
showDetailPanel.value = false
selectedTaskId.value = null
@@ -239,8 +298,11 @@ async function saveTaskDetail() {
dueDate: detailForm.dueDate || null,
})
if (detailForm.state !== selectedTask.value.state) {
// Nur Iris/Bao darf Status ändern
if (canChangeState.value && detailForm.state !== selectedTask.value.state) {
await taskStore.moveTask(selectedTask.value.id, detailForm.state)
} else if (detailForm.state !== selectedTask.value.state) {
detailForm.state = selectedTask.value.state // revert state change in UI
}
detailSuccess.value = 'Änderungen gespeichert'
@@ -269,7 +331,10 @@ watch(showDetailPanel, (open) => {
/* ── Lifecycle ────────────────────────────────────── */
onMounted(() => {
taskStore.startBoardPolling()
taskStore.fetchAgentOverview()
window.addEventListener('keydown', onGlobalKeydown)
// Refresh agent overview on the same interval
setInterval(() => taskStore.fetchAgentOverview(), 30000)
})
onBeforeUnmount(() => {
@@ -289,10 +354,93 @@ onUnmounted(() => {
<h1><span class="grad-text">Aufgaben</span></h1>
<p class="board-subtitle">Task Board Übersicht aller Arbeitspakete</p>
</div>
<button class="create-btn" @click="showCreateModal = true">
<Plus :size="16" />
Neue Aufgabe
</button>
<div class="board-header-actions">
<button
v-if="staleCount > 0 || waitingForIrisCount > 0"
class="iris-panel-btn"
@click="showIrisPanel = !showIrisPanel"
>
<Eye :size="14" />
Iris-Blick
<span v-if="waitingForIrisCount > 0" class="panel-badge iris-badge">{{ waitingForIrisCount }}</span>
<span v-if="staleCount > 0" class="panel-badge stale-badge">{{ staleCount }}</span>
</button>
<button class="create-btn" @click="showCreateModal = true">
<Plus :size="16" />
Neue Aufgabe
</button>
</div>
</div>
<!-- Status-Change Permission Banner -->
<div v-if="!canChangeState" class="permission-banner">
<ShieldBan :size="14" />
<span><strong>Nur-Lesen-Status.</strong> Du kannst Aufgaben inhaltlich bearbeiten (Titel, Beschreibung, Priorität, Zuständigkeit), aber das Verschieben/Status-Ändern ist <strong>nur Iris und Bao</strong> vorbehalten.</span>
</div>
<!-- Stale Warning Banner -->
<div v-if="staleCount > 0" class="stale-banner">
<AlertTriangle :size="14" />
<span><strong>{{ staleCount }} Task(s)</strong> sind stale (InBearbeitung/Delegiert &gt; 2h ohne Update).</span>
<button class="stale-dismiss" @click="showIrisPanel = true">Ansehen</button>
</div>
<!-- Iris Overview Panel (collapsible) -->
<div v-if="showIrisPanel" class="iris-panel">
<div class="iris-panel-header">
<h3><Bot :size="16" /> Iris Worauf warte ich?</h3>
<button class="modal-close" @click="showIrisPanel = false">&times;</button>
</div>
<div v-if="taskStore.agentOverviewLoading" class="iris-loading">Lade Übersicht</div>
<div v-else class="iris-panel-grid">
<section class="iris-section">
<div class="iris-section-title">
<span class="section-dot iris-dot"></span>
Warte auf Iris <span class="section-count">{{ waitingForIrisCount }}</span>
</div>
<div v-if="taskStore.waitingForIrisTasks.length === 0" class="iris-empty">Keine Tasks</div>
<div v-for="t in taskStore.waitingForIrisTasks" :key="t.id" class="iris-task-row">
<span class="iris-task-title">{{ t.title }}</span>
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
</div>
</section>
<section class="iris-section">
<div class="iris-section-title">
<span class="section-dot bao-dot"></span>
Warte auf Bao <span class="section-count">{{ waitingForBaoCount }}</span>
</div>
<div v-if="taskStore.waitingForBaoTasks.length === 0" class="iris-empty">Keine Tasks</div>
<div v-for="t in taskStore.waitingForBaoTasks" :key="t.id" class="iris-task-row">
<span class="iris-task-title">{{ t.title }}</span>
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
</div>
</section>
<section class="iris-section">
<div class="iris-section-title">
<span class="section-dot other-dot"></span>
Warte auf andere <span class="section-count">{{ taskStore.waitingForOthersTasks.length }}</span>
</div>
<div v-if="taskStore.waitingForOthersTasks.length === 0" class="iris-empty">Keine Tasks</div>
<div v-for="t in taskStore.waitingForOthersTasks" :key="t.id" class="iris-task-row">
<span class="iris-task-title">{{ t.title }}</span>
<span class="iris-task-meta">{{ expectedFromLabel(t.expectedFrom) }}</span>
</div>
</section>
<section class="iris-section iris-section-stale">
<div class="iris-section-title">
<span class="section-dot stale-dot"></span>
Stale Tasks <span class="section-count stale-count">{{ staleCount }}</span>
</div>
<div v-if="taskStore.staleTasksList.length === 0" class="iris-empty">Keine stale Tasks</div>
<div v-for="t in taskStore.staleTasksList" :key="t.id" class="iris-task-row stale-row">
<span class="iris-task-title">{{ t.title }}</span>
<span class="iris-task-meta stale-meta">{{ hoursSince(t.updatedAt) }}h offen</span>
</div>
</section>
</div>
</div>
<div v-if="taskStore.boardLoading" class="board-loading">
@@ -319,8 +467,9 @@ onUnmounted(() => {
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="openTask(task.id)"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
@@ -328,6 +477,10 @@ onUnmounted(() => {
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -362,8 +515,9 @@ onUnmounted(() => {
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="openTask(task.id)"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
@@ -371,6 +525,10 @@ onUnmounted(() => {
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -405,8 +563,9 @@ onUnmounted(() => {
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="openTask(task.id)"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
@@ -414,6 +573,10 @@ onUnmounted(() => {
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -448,8 +611,9 @@ onUnmounted(() => {
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="openTask(task.id)"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
@@ -457,6 +621,10 @@ onUnmounted(() => {
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -492,7 +660,7 @@ onUnmounted(() => {
type="button"
class="card"
draggable="true"
@click="openTask(task.id)"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
@@ -534,8 +702,9 @@ onUnmounted(() => {
:key="task.id"
type="button"
class="card card-blocked"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="openTask(task.id)"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
@@ -543,6 +712,10 @@ onUnmounted(() => {
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -602,6 +775,9 @@ onUnmounted(() => {
<select id="task-assignee" v-model="formAssignedTo" class="field-input field-select">
<option value="bao">👤 Bao</option>
<option value="iris">🤖 Iris</option>
<option value="programmer">🛠 Programmer</option>
<option value="reviewer">🔎 Reviewer</option>
<option value="architekt">🏛 Architekt</option>
</select>
</div>
</div>
@@ -634,6 +810,8 @@ onUnmounted(() => {
<input v-model="detailForm.title" class="detail-title-input" maxlength="240" />
<div class="detail-meta-row">
<span>#{{ selectedTask.id.slice(0, 8) }}</span>
<span v-if="selectedTask.isAgentTask" class="meta-agent-tag">🤖 Agent-Task</span>
<span v-if="selectedTask.expectedFrom" class="meta-expected"> Erwartet: {{ selectedTask.expectedFrom }}</span>
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
</div>
@@ -686,8 +864,13 @@ onUnmounted(() => {
<section class="sidebar-card">
<div class="sidebar-heading">Eigenschaften</div>
<label class="sidebar-field">
<span>Status</span>
<select v-model="detailForm.state" class="field-input field-select slim">
<span>Status <span v-if="!canChangeState" class="readonly-tag">(nur Iris/Bao)</span></span>
<select
v-model="detailForm.state"
class="field-input field-select slim"
:disabled="!canChangeState"
:title="!canChangeState ? 'Statusänderungen sind nur Iris und Bao vorbehalten' : ''"
>
<option value="Backlog">Offen</option>
<option value="In progress">In Bearbeitung</option>
<option value="Delegated">Delegiert</option>
@@ -736,6 +919,14 @@ onUnmounted(() => {
<dt>Fällig</dt>
<dd>{{ formatDate(selectedTask.dueDate) }}</dd>
</div>
<div v-if="selectedTask.isAgentTask">
<dt>Agent-Task</dt>
<dd>🤖 Ja</dd>
</div>
<div v-if="selectedTask.expectedFrom">
<dt>Erwartet von</dt>
<dd>{{ selectedTask.expectedFrom }}</dd>
</div>
</dl>
</section>
@@ -745,9 +936,9 @@ onUnmounted(() => {
<div class="detail-actions">
<button type="button" class="btn-cancel" @click="closeDetailPanel">Schließen</button>
<button type="button" class="btn-ghost" @click="router.push('/tasks/' + selectedTask.id)">
<ExternalLink :size="13" /> Vollansicht
<ExternalLink :size="13" /> Vollansicht öffnen
</button>
<button type="button" class="btn-submit" :disabled="!canSaveDetail" @click="saveTaskDetail">
<button type="button" class="btn-submit" :disabled="!canSaveDetail || (detailForm.state !== selectedTask.state && !canChangeState)" @click="saveTaskDetail">
<Save :size="14" />
{{ detailSaving ? 'Speichert…' : 'Speichern' }}
</button>
@@ -774,15 +965,55 @@ onUnmounted(() => {
.board-header h1 { margin: 0; font-size: 22px; font-weight: 700; font-family: 'Space Grotesk', sans-serif; letter-spacing: -0.02em; }
.grad-text { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
.board-subtitle { margin: 4px 0 0; font-size: 11px; color: var(--tx-3); font-family: 'Manrope', sans-serif; }
.board-header-actions { display: flex; align-items: center; gap: 8px; }
.create-btn { display: flex; align-items: center; gap: 6px; padding: 8px 16px; border: none; border-radius: var(--r-sm, 10px); background: var(--grad); color: #fff; font-size: 12.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: opacity .15s, transform .15s; flex-shrink: 0; box-shadow: var(--glow-purple); }
.create-btn:hover { opacity: .85; transform: translateY(-1px); }
.create-btn:active { transform: translateY(0); }
.iris-panel-btn { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border: 1px solid var(--a-mid); border-radius: var(--r-sm, 10px); background: rgba(124,108,255,.10); color: var(--a-mid); font-size: 12px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: background .15s, border-color .15s; }
.iris-panel-btn:hover { background: rgba(124,108,255,.18); }
.panel-badge { font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 6px; }
.iris-badge { background: rgba(147, 51, 234, .25); color: #c084fc; }
.stale-badge { background: rgba(244, 63, 94, .25); color: #fda4af; }
/* Stale Banner */
.stale-banner { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: var(--r-sm, 10px); background: rgba(244,63,94,.10); border: 1px solid rgba(244,63,94,.25); color: #fda4af; font-size: 12px; font-family: 'Manrope', sans-serif; }
.stale-dismiss { margin-left: auto; padding: 4px 12px; border: 1px solid rgba(244,63,94,.3); border-radius: 8px; background: transparent; color: #fda4af; font-size: 11px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; }
/* Iris Panel */
.iris-panel { background: var(--glass); border: 1px solid var(--line-2); border-radius: var(--r, 14px); padding: 16px; backdrop-filter: blur(12px); }
.iris-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
.iris-panel-header h3 { margin: 0; font-size: 14px; font-weight: 700; color: var(--tx); display: flex; align-items: center; gap: 8px; font-family: 'Space Grotesk', sans-serif; }
.iris-loading { padding: 20px; text-align: center; color: var(--tx-3); font-size: 12px; }
.iris-panel-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.iris-section { background: rgba(255,255,255,.02); border: 1px solid var(--line); border-radius: 12px; padding: 12px; }
.iris-section-title { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--tx-2); margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
.section-dot { width: 8px; height: 8px; border-radius: 50%; }
.iris-dot { background: #c084fc; }
.bao-dot { background: #60a5fa; }
.other-dot { background: #fb923c; }
.stale-dot { background: #f87171; }
.section-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; padding: 1px 6px; border-radius: 6px; background: var(--glass-2); color: var(--tx-2); }
.stale-count { background: rgba(244,63,94,.15); color: #fda4af; }
.iris-empty { font-size: 11px; color: var(--tx-3); font-style: italic; padding: 8px; text-align: center; }
.iris-task-row { padding: 6px 8px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.iris-task-row:last-child { border-bottom: none; }
.iris-task-title { font-size: 11.5px; font-weight: 500; color: var(--tx); }
.iris-task-meta { font-size: 10px; color: var(--tx-3); white-space: nowrap; }
.stale-row { background: rgba(244,63,94,.05); border-radius: 4px; }
.stale-meta { color: #fda4af; font-weight: 600; }
.iris-section-stale { border-color: rgba(244,63,94,.25); background: rgba(244,63,94,.04); }
.board-loading { display: flex; align-items: center; gap: 10px; padding: 40px; color: var(--tx-3); font-size: 13px; font-family: 'Manrope', sans-serif; }
.spinner { width: 20px; height: 20px; border: 2px solid var(--line-2); border-top-color: var(--a-mid); border-radius: 50%; animation: spin .6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.board-columns { display: flex; gap: 14px; flex: 1; overflow-x: auto; padding-bottom: 20px; min-height: calc(100vh - 200px); scrollbar-width: thin; scrollbar-color: rgba(124,108,255,.22) transparent; }
.col { flex: 1; min-width: 240px; max-width: 320px; display: flex; flex-direction: column; background: var(--glass); border: 1px solid var(--line); border-radius: var(--r, 14px); padding: 12px; transition: border-color .2s, background .2s; backdrop-filter: blur(12px); }
.col.drag-over { border-color: var(--a-mid); background: linear-gradient(160deg, rgba(124,108,255,.10), rgba(20,17,48,.55)); box-shadow: 0 0 0 1px rgba(124,108,255,.15); }
/* Permission Banner */
.permission-banner { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: var(--r-sm, 10px); background: rgba(147,51,234,.08); border: 1px solid rgba(147,51,234,.2); color: #c084fc; font-size: 12px; font-family: 'Manrope', sans-serif; }
.readonly-tag { font-weight: 400; color: var(--tx-3); font-size: 10px; text-transform: none; }
select:disabled { opacity: .45; cursor: not-allowed; }
.col-blocked { max-width: 240px; }
.col-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
.col-icon { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
@@ -794,8 +1025,11 @@ onUnmounted(() => {
.card:active { cursor: grabbing; }
.card.dragging { opacity: .4; cursor: grabbing; }
.card-blocked { border-left: 3px solid var(--st-block); }
.card-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.card-agent { border-left: 2px solid rgba(124,108,255,.3); }
.card-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
.prio-badge { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
.agent-badge { font-size: 11px; line-height: 1; }
.expected-badge { font-family: 'JetBrains Mono', monospace; font-size: 8px; font-weight: 600; padding: 1px 5px; border-radius: 4px; background: rgba(147,51,234,.08); color: #a78bfa; border: 1px solid rgba(147,51,234,.15); }
.assignee { font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
.assignee-iris { background: rgba(147, 51, 234, .12); color: #c084fc; }
.assignee-bao { background: rgba(59, 130, 246, .12); color: #60a5fa; }
@@ -803,6 +1037,8 @@ onUnmounted(() => {
.card-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card-meta { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--tx-3); margin-top: 5px; font-variant-numeric: tabular-nums; }
.empty-col, .detail-empty { display: flex; align-items: center; justify-content: center; padding: 24px 12px; font-size: 11px; color: var(--tx-3); font-style: italic; font-family: 'Manrope', sans-serif; }
/* Modal / Detail shared styles */
.modal-overlay, .detail-overlay { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(5,4,16,.75); backdrop-filter: blur(16px); }
.modal-card { width: 100%; max-width: 460px; background: linear-gradient(160deg, rgba(20,17,48,.85), rgba(14,12,32,.85)); border: 1px solid var(--line-2); border-radius: var(--r, 14px); padding: 24px; box-shadow: 0 0 0 1px rgba(124,108,255,.12), 0 20px 60px -12px rgba(0,0,0,.5); backdrop-filter: blur(12px); }
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
@@ -829,6 +1065,8 @@ onUnmounted(() => {
.btn-submit { padding: 7px 18px; border: none; border-radius: var(--r-sm, 10px); background: var(--grad); color: #fff; font-size: 12px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: opacity .15s, transform .15s; box-shadow: var(--glow-purple); display: inline-flex; align-items: center; gap: 6px; }
.btn-submit:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.btn-submit:not(:disabled):hover { opacity: .85; transform: translateY(-1px); }
/* Detail Panel */
.detail-panel { width: min(1120px, calc(100vw - 48px)); height: min(88vh, 860px); display: flex; flex-direction: column; background: linear-gradient(180deg, rgba(13,11,28,.97), rgba(10,9,24,.96)); border: 1px solid rgba(124,108,255,.18); border-radius: 22px; box-shadow: 0 28px 90px rgba(0,0,0,.45); overflow: hidden; }
.detail-topbar { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--line); }
.detail-breadcrumb { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--tx-3); }
@@ -846,6 +1084,8 @@ onUnmounted(() => {
.detail-title-input { background: transparent; border: 1px solid transparent; border-radius: 12px; padding: 0; color: var(--tx); font-family: 'Space Grotesk', sans-serif; font-size: 31px; font-weight: 700; letter-spacing: -0.03em; outline: none; }
.detail-meta-row { display: flex; flex-wrap: wrap; gap: 14px; color: var(--tx-3); font-size: 11.5px; }
.detail-meta-row span { display: inline-flex; align-items: center; gap: 5px; }
.meta-agent-tag { color: #c084fc; }
.meta-expected { color: #a78bfa; }
.detail-section { background: rgba(255,255,255,.02); border: 1px solid var(--line); border-radius: 16px; padding: 16px; }
.detail-section-header, .sidebar-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--tx-2); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.detail-textarea { width: 100%; min-height: 180px; border-radius: 12px; border: 1px solid var(--line); background: rgba(10,9,24,.55); color: var(--tx); padding: 14px; font-size: 14px; line-height: 1.6; outline: none; box-sizing: border-box; }
@@ -875,4 +1115,6 @@ onUnmounted(() => {
.board-columns::-webkit-scrollbar-track { background: transparent; }
@media (max-width: 1100px) { .detail-content { grid-template-columns: 1fr; } .detail-sidebar { border-left: none; border-top: 1px solid var(--line); } }
@media (max-width: 860px) { .board-columns { overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; } .col { min-width: 260px; } .detail-panel { width: 100vw; height: 100vh; border-radius: 0; } .detail-grid { grid-template-columns: 1fr; } .detail-main, .detail-sidebar { padding: 18px; } .detail-title-input { font-size: 24px; } }
@media (max-width: 900px) { .iris-panel-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .iris-panel-grid { grid-template-columns: 1fr; } }
</style>